React Hook Form i React Native 2026: Typsäkra formulär med Zod

Så bygger du typsäkra formulär i React Native 2026 med React Hook Form och Zod. Controller-mönster, validering, serverfel och komplett login-exempel.

React Hook Form React Native Guide 2026

Uppdaterad: 7 augusti 2026

React Hook Form är det snabbaste sättet att bygga typsäkra formulär i React Native 2026. Du använder useForm tillsammans med Controller för varje TextInput, kopplar in ett Zod-schema via zodResolver, och får både runtime-validering och TypeScript-typer från exakt samma källa. Kommer du från React på webben känns 80 procent igen. Men Controller, tangentbordet och avsaknaden av ref-baserad register är de tre ställen där mobilen bryter mönstret. Den här guiden går igenom hela flödet med kod som körs mot Expo SDK 55 och React Native 0.82.

  • React Hook Form v7.55+ kräver Controller för varje TextInput i React Native. Ref-baserad register fungerar inte som på webben.
  • Zod 4 och @hookform/resolvers/zod ger dig ett enda schema som producerar både TypeScript-typer (via z.infer) och runtime-validering.
  • Standardvalideringsläget mode: 'onBlur' är oftast rätt för mobilformulär, eftersom onChange-validering med varje tangentnedslag känns rastlöst.
  • Serverfel sätts med setError(field, { type: 'server', message }) så att felmeddelanden återanvänder samma UI som klientvalideringen.
  • React Hook Form minimerar omrenderingar genom att isolera fältstate. Bara det ändrade fältet renderas om, inte hela formuläret.
  • Kombinationen fungerar med Expo Router, Expo SDK 55 och New Architecture utan native moduler eller extra config.

Varför React Hook Form i React Native 2026?

Om du kommer från webben har du förmodligen använt antingen React Hook Form, Formik eller egen useState-baserad formulärkod. På mobilen är valet 2026 mer eller mindre avgjort. React Hook Form v7.55+ är standardvalet, Formik saknar underhåll (senaste stabila releasen är över två år gammal), och egna useState-lösningar blir snabbt röriga när du lägger till validering, felmeddelanden och asynkron submit.

Ärligt talat: jag testade själv att köra ett registreringsflöde med useState i mitt förra projekt, och efter fjärde fältet började det se ut som en bunt spaghetti. Bytet till React Hook Form halverade koden.

Det som gör React Hook Form intressant för React Native är arkitekturen. Biblioteket bygger på ett register-mönster där varje fält prenumererar på sitt eget slice av formulärstate. När jag skriver i ett fält renderas alltså bara det fältet om, inte hela formuläret. På webben görs detta med refs och okontrollerade inputs. På mobilen, där TextInput inte har samma ref-API, sker samma optimering via Controller-komponenten som gör en kontrollerad wrapper runt ditt fält utan att förlora prestandan.

Zod är sedan cirka två år standardvalet för runtime-validering i TypeScript-projekt. I React Native 2026 fungerar Zod 4 utan kompromisser. Den är hermes-kompatibel, har inga native beroenden, och integrationen med React Hook Form sker genom @hookform/resolvers/zod som auto-detekterar Zod 3 eller Zod 4. Samma resolver-anrop fungerar i båda versionerna. Läs gärna React Hook Forms officiella dokumentation för fullständig API-referens.

Installation och grundläggande setup

Installationen är minimal och kräver ingen native länkning. Kör följande i ditt Expo- eller React Native-projekt:

npx expo install react-hook-form @hookform/resolvers zod

Notera att jag använder npx expo install snarare än npm install. Det ger dig versionsintervall som är validerade mot din Expo SDK. På ett rent React Native-projekt utan Expo kör du samma paket via npm install eller yarn add.

De tre paketen har olika roller. react-hook-form ger dig useForm-hooken, Controller-komponenten och verktyg som useFieldArray. zod är själva valideringsbiblioteket där du beskriver formens dataschema. Och @hookform/resolvers/zod är bron mellan de två: den läser ditt Zod-schema, kör valideringen när formuläret submittas och returnerar fel i det format React Hook Form förväntar sig.

Skapa sedan ett minimalt formulär för att verifiera att allt fungerar:

import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { View, TextInput, Text, Button } from 'react-native';

const schema = z.object({
  email: z.string().email('Ogiltig e-postadress'),
});

type FormData = z.infer<typeof schema>;

export function MinimalForm() {
  const { control, handleSubmit, formState: { errors } } = useForm<FormData>({
    resolver: zodResolver(schema),
    mode: 'onBlur',
  });

  const onSubmit = (data: FormData) => console.log(data);

  return (
    <View>
      <Controller
        control={control}
        name="email"
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            value={value}
            onChangeText={onChange}
            onBlur={onBlur}
            placeholder="E-post"
            autoCapitalize="none"
            keyboardType="email-address"
          />
        )}
      />
      {errors.email && <Text>{errors.email.message}</Text>}
      <Button title="Skicka" onPress={handleSubmit(onSubmit)} />
    </View>
  );
}

Om du har byggt formulär på webben tidigare är det bara två saker som skiljer sig här: Controller istället för {...register('email')}, samt onChangeText istället för onChange. Resten (handleSubmit, formState, resolver) är identiskt.

Controller-mönstret: skillnaden från webben

Här är det första stället där mobil-Reacten bryter mot webbens mönster. På webben skulle du skriva <input {...register('email')} /> och biblioteket skulle koppla in en ref som läser inputvärdet okontrollerat. Noll omrenderingar, maximal prestanda. React Natives TextInput har visserligen en ref, men den exponerar inte inputvärdet direkt. Alla React Native-inputs är kontrollerade komponenter i praktiken.

Lösningen är Controller. Den fungerar som en tunn wrapper runt fältet där du får tre callback-props via render: onChange, onBlur och value. Du kopplar dem till respektive TextInput-prop, och Controller isolerar omrenderingarna så att bara det aktuella fältet renderas om, inte hela formuläret.

En viktig detalj: onChange från Controller har samma signatur som HTML-onChange (en event-handler), medan TextInput har onChangeText som får en sträng direkt. React Hook Form är smart nog att acceptera båda, så du kan skicka onChange direkt till onChangeText. Biblioteket bryr sig bara om att få ett värde tillbaka.

<Controller
  control={control}
  name="password"
  render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => (
    <>
      <TextInput
        value={value}
        onChangeText={onChange}
        onBlur={onBlur}
        secureTextEntry
        placeholder="Lösenord"
      />
      {error && <Text style={{ color: 'red' }}>{error.message}</Text>}
    </>
  )}
/>

Lägg märke till att jag använder fieldState.error direkt från render-callbacken istället för att gå via formState.errors.password. Båda fungerar, men fieldState är fältisolerat. Det utlöser inte en omrendering av hela formuläret bara för att ett annat fält får ett fel.

Zod-scheman och typsäker validering

Det verkliga värdet av kombinationen kommer från Zod. Ett schema som z.object({...}) är både din runtime-validering och din TypeScript-typ. Du skriver aldrig samma fält två gånger, en gång i en interface och en gång i validering. Ett schema, en källa till sanning.

import { z } from 'zod';

const registerSchema = z.object({
  email: z.string().email('Ogiltig e-postadress'),
  password: z.string().min(8, 'Minst 8 tecken')
    .regex(/[A-Z]/, 'Minst en versal')
    .regex(/[0-9]/, 'Minst en siffra'),
  confirmPassword: z.string(),
  age: z.coerce.number().min(18, 'Du måste vara minst 18 år'),
  acceptTerms: z.boolean().refine(v => v === true, {
    message: 'Du måste acceptera villkoren',
  }),
}).refine((data) => data.password === data.confirmPassword, {
  message: 'Lösenorden matchar inte',
  path: ['confirmPassword'],
});

type RegisterData = z.infer<typeof registerSchema>;

Notera z.coerce.number(). TextInput ger dig alltid en sträng, men schemat säger att fältet ska vara ett tal. coerce kör en tvångsvis konvertering innan valideringen och är exakt det du vill ha för åldern här. Utan coerce skulle Zod avvisa strängen "25" med "Expected number, received string".

Cross-field-validering (t.ex. att lösenord och bekräftelse matchar) sker via .refine() på hela objektet. Sätt path så att felet knyts till rätt fält, annars hamnar felmeddelandet på formulärnivå och du får inte hjälpen av fieldState.error.

Fördelen med typinferens är att du får kompileringsfel om du försöker läsa data.emial istället för data.email i din submit-handler. På webben löser många detta med separata TypeScript-interfaces plus Yup, vilket innebär att du underhåller två parallella källor och de glider isär över tid. Zod skär bort det problemet helt. Se den officiella Zod-dokumentationen för hela API-ytan.

Komplett login-formulär från grunden

Här är ett komplett, körbart login-formulär. Jag har medvetet skrivit ut alla delar (inklusive den där <FormField>-wrappen från tipset ovan) så att du kan kopiera in det i ett Expo-projekt och köra det utan att pussla ihop delarna själv.

import { useForm, Controller, Control } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { View, TextInput, Text, Pressable, StyleSheet } from 'react-native';

const loginSchema = z.object({
  email: z.string().email('Ogiltig e-postadress'),
  password: z.string().min(1, 'Lösenord krävs'),
});

type LoginData = z.infer<typeof loginSchema>;

type FieldProps = {
  control: Control<LoginData>;
  name: keyof LoginData;
  placeholder: string;
  secureTextEntry?: boolean;
  keyboardType?: 'default' | 'email-address';
};

function FormField({ control, name, placeholder, secureTextEntry, keyboardType }: FieldProps) {
  return (
    <Controller
      control={control}
      name={name}
      render={({ field: { onChange, onBlur, value }, fieldState: { error } }) => (
        <View style={styles.field}>
          <TextInput
            style={[styles.input, error && styles.inputError]}
            value={value}
            onChangeText={onChange}
            onBlur={onBlur}
            placeholder={placeholder}
            autoCapitalize="none"
            secureTextEntry={secureTextEntry}
            keyboardType={keyboardType}
          />
          {error && <Text style={styles.errorText}>{error.message}</Text>}
        </View>
      )}
    />
  );
}

export function LoginForm() {
  const { control, handleSubmit, formState: { isSubmitting } } = useForm<LoginData>({
    resolver: zodResolver(loginSchema),
    mode: 'onBlur',
    defaultValues: { email: '', password: '' },
  });

  const onSubmit = async (data: LoginData) => {
    await new Promise((r) => setTimeout(r, 500));
    console.log('Skickar:', data);
  };

  return (
    <View style={styles.container}>
      <FormField
        control={control}
        name="email"
        placeholder="E-post"
        keyboardType="email-address"
      />
      <FormField
        control={control}
        name="password"
        placeholder="Lösenord"
        secureTextEntry
      />
      <Pressable
        style={[styles.button, isSubmitting && styles.buttonDisabled]}
        onPress={handleSubmit(onSubmit)}
        disabled={isSubmitting}
      >
        <Text style={styles.buttonText}>
          {isSubmitting ? 'Loggar in…' : 'Logga in'}
        </Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { padding: 16, gap: 12 },
  field: { gap: 4 },
  input: {
    borderWidth: 1, borderColor: '#ccc', borderRadius: 8,
    padding: 12, fontSize: 16,
  },
  inputError: { borderColor: '#e11d48' },
  errorText: { color: '#e11d48', fontSize: 12 },
  button: {
    backgroundColor: '#2563eb', padding: 14,
    borderRadius: 8, alignItems: 'center',
  },
  buttonDisabled: { opacity: 0.6 },
  buttonText: { color: '#fff', fontWeight: '600' },
});

Två saker att lägga märke till. För det första: defaultValues är satt till tomma strängar. Om du hoppar över detta blir value initialt undefined, vilket React Native TextInput behandlar som en okontrollerad komponent, och sen får du varningen om att du bytte från okontrollerad till kontrollerad när användaren skriver första bokstaven. Sätt alltid defaultValues. (Jag har fastnat på just den varningen fler gånger än jag vill erkänna.)

För det andra: isSubmitting från formState är sann under hela asynkrona submit-flödet. Använd den för att disabla submit-knappen så att användaren inte kan skicka två gånger genom att dubbeltrycka. På mobil, med lite latens över nätet, är detta ett vanligt tapp.

Hur hanterar man serverfel i React Hook Form?

Klientvalidering är hälften av jobbet. Den andra hälften är att visa serverfel i samma UI. Om servern säger att e-postadressen redan är registrerad vill du helst visa det direkt under e-postfältet, precis som du visar "Ogiltig e-postadress" när Zod klagar. React Hook Form löser detta med setError:

const { control, handleSubmit, setError, formState: { errors } } = useForm<RegisterData>({
  resolver: zodResolver(registerSchema),
});

const onSubmit = async (data: RegisterData) => {
  try {
    const res = await fetch('https://api.example.com/register', {
      method: 'POST',
      body: JSON.stringify(data),
    });
    if (!res.ok) {
      const body = await res.json();
      if (body.errors) {
        Object.entries(body.errors).forEach(([field, message]) => {
          setError(field as keyof RegisterData, {
            type: 'server',
            message: message as string,
          });
        });
      }
      return;
    }
  } catch (err) {
    setError('root', {
      type: 'network',
      message: 'Kunde inte kontakta servern. Försök igen.',
    });
  }
};

Två tips här. För fältspecifika fel (t.ex. "e-postadressen är upptagen"), sätt felet på fältet med type: 'server' så att du vid nästa onBlur eller submit kan låta klientvalideringen skriva över. För globala fel (nätverk, generellt serverfel), använd setError('root', ...) och visa det i toppen av formuläret via formState.errors.root.

Prestandaknep: minska omrenderingar

Prestandan är React Hook Forms största fördel jämfört med Formik eller egen useState-baserad kod. Men det finns fortfarande sätt att förstöra den. Här är de tre viktigaste knepen jag använder i produktion:

1. Använd fieldState istället för formState. Om du läser formState.errors.email i en förälder-komponent kommer den föräldern att renderas om varje gång något fel ändras. Läs istället fieldState.error inuti Controller.render, då isoleras felet till fältets egen render-scope.

2. Undvik watch när du kan. watch('email') prenumererar på fältet och renderar om komponenten vid varje ändring. Behöver du bara läsa värdet vid submit? Använd getValues('email') istället, det är en ren läsning utan prenumeration.

3. Håll defaultValues stabila. Om du skickar in ett nytt objekt-literal vid varje rendering (defaultValues={{ email: '' }}) tänker React Hook Form att formuläret har återställts och tvingar en full omrendering. Deklarera objektet utanför komponenten, eller memoisera det med useMemo.

För djupare prestandaknep i mobilappar, se min guide till React Native prestandaoptimering med New Architecture och React Compiler. Flera av principerna där gäller lika mycket för formulär som för listor och navigation.

Fungerar React Hook Form med Expo?

Ja, React Hook Form fungerar utan modifikationer med Expo SDK 55, Expo Router v7 och Expo Go. Ingen av paketen (react-hook-form, zod, @hookform/resolvers) har native beroenden. Allt är rent JavaScript, kompatibelt med Hermes och New Architecture. Se Expos SDK-dokumentation för aktuella kompatibilitetsanteckningar.

Det finns en enda sak att tänka på med Expo Router: när användaren navigerar bort från en skärm med ett formulär och sedan tillbaka, återmonteras komponenten som standard och formuläret återställs. Detta är oftast önskvärt (rensa hemligheter från minnet, undvik läckor), men om du vill behålla state över navigering behöver du antingen lyfta formuläret till en context/store eller använda unstable_settings.initialRouteName i Expo Router för att hålla skärmen monterad.

För större formulär som spänner över flera skärmar (t.ex. onboarding-flöden) är det oftast bättre att flytta formulärstate till en Zustand-store som beskrivs i min guide till state management i React Native. Då överlever data mellan skärmar och du kan validera hela wizarden med ett enda Zod-schema vid sista steget.

Vanliga fallgropar och hur du undviker dem

Efter att ha migrerat en handfull produktionsappar från Formik och egen kod till React Hook Form har jag samlat på mig ett antal felaktiga antaganden som är värda att peka ut. Dessa är de fem vanligaste jag ser hos utvecklare som kommer från webben.

Fallgrop 1: register istället för Controller. På webben kan du skriva <input {...register('email')} /> och det bara fungerar. På mobil måste du använda Controller. Blandar du in register kommer värdet aldrig att sparas i formulärstate och submit får bara tomma fält. Jag råkade ut för exakt det här när jag portade ett webbformulär till Expo och undrade i tio minuter varför data alltid var tom.

Fallgrop 2: Glömma defaultValues. Utan defaultValues är initialt fältvärde undefined, TextInput ser det som okontrollerat, och React varnar "changing an uncontrolled component to controlled". Fixet är alltid att sätta tomma strängar (eller lämpligt initialt värde) i defaultValues.

Fallgrop 3: Använda onChange istället för onChangeText. TextInput har båda, men onChange ger ett event-object medan onChangeText ger en sträng. React Hook Form vill ha strängen, så koppla alltid onChangeText={field.onChange}.

Fallgrop 4: keyboardType="number-pad" utan z.coerce.number(). Även om tangentbordet är numeriskt returnerar TextInput fortfarande en sträng. Utan z.coerce.number() i schemat kastar Zod fel på submit. Alternativt, transformera med onChange={(v) => field.onChange(parseInt(v, 10))}, men coerce är renare.

Fallgrop 5: Att lita på klientvalidering. Zod på klienten är UX, inte säkerhet. Kör alltid samma schema på backend också, antingen genom att dela schemat mellan klient och server (om båda är TypeScript) eller genom att duplicera valideringen. Utan backendvalidering är din API sårbar för klienter som kringgår formuläret helt.

Vanliga frågor

Vad är skillnaden mellan Yup och Zod för React Hook Form?

Zod är TypeScript-först: schemat producerar automatiskt en TypeScript-typ via z.infer. Yup är äldre och kräver att du underhåller en separat interface parallellt med schemat. För nya projekt 2026 är Zod standardvalet. Yup är fortfarande underhållet men saknar samma typinferens ur lådan.

Är React Hook Form snabbare än Formik i React Native?

Ja, betydligt. React Hook Form isolerar fältstate så att bara det aktiva fältet renderas om. Formik renderar om hela formuläret vid varje tangenttryckning. På formulär med tio eller fler fält är skillnaden märkbar även på snabba enheter. Formik är dessutom underhållstunt sedan flera år tillbaka.

Kan man använda React Hook Form med checkboxar och switchar i React Native?

Ja, samma Controller-mönster fungerar för alla kontrollerade komponenter. För Switch kopplar du value till field.value och onValueChange till field.onChange. Se till att sätta defaultValues till false för booleska fält, annars blir de undefined initialt.

Hur validerar man formulär med flera steg (wizard) i React Native?

Två strategier: antingen ett stort Zod-schema med trigger(['field1', 'field2']) för att validera bara aktuella steg innan navigering, eller separata scheman per steg som slås ihop med z.intersection vid final submit. Den senare är renare för långa wizards.

Behöver React Hook Form några native moduler i React Native?

Nej. React Hook Form, Zod och @hookform/resolvers är alla rena JavaScript-paket. De fungerar i Expo Go, med Expo SDK 55, med Hermes och med New Architecture utan native länkning eller extra config. Detta är en av anledningarna till att kombinationen är så populär i mobilappar.

Anita Iyer
Om Författaren Anita Iyer

Cross-platform mobile developer who came to RN from web. Bridges the two worlds and explains the seams.