React Native forme u 2026: React Hook Form, Zod i Controller uzorci

Praktičan vodič za forme u React Nativeu 2026: kombinacija React Hook Form 7.80, Zod 4 sheme i Controller uzorka za tipovima sigurne obrasce, minimalne re-rendere i validaciju koja se dijeli s backendom.

React Native Forme 2026: RHF + Zod Vodič

Ažurirano: 11. srpnja 2026.

Za forme u React Nativeu 2026 najbolja kombinacija je React Hook Form 7.80 uz Zod 4 shemu i Controller komponentu koja povezuje TextInput s validacijom. Dobijete tipovima siguran obrazac, minimalan broj re-rendera i shemu koja se dijeli s backendom. Ako ste dolazili s weba, dobra vijest je da je API gotovo identičan onome u React DOM projektima. Ono što se razlikuje su ref-ovi, tipkovnica i fokus, i upravo te «spojnice» pokrivamo u ovom vodiču korak po korak.

  • React Hook Form 7.80 i Zod 4.4 su stabilan izbor za produkciju u 2026; @hookform/resolvers 5.4 automatski prepoznaje verziju Zoda.
  • U React Nativeu se koristi Controller umjesto register, jer TextInput ne prima ref na isti način kao HTML input.
  • Tipovi se izvode iz Zod sheme s z.infer<typeof schema>, pa nema duplog definiranja tipova i validacijskih pravila.
  • Fokus na sljedeće polje rješava setFocus iz useForm u kombinaciji s returnKeyType="next" i onSubmitEditing.
  • Za scrollane forme koristite react-native-keyboard-controller; KeyboardAvoidingView je dovoljan samo za fiksne, kratke ekrane.
  • Async validacija (npr. provjera slobodnog usernama) piše se kroz refine na Zod shemi i pokreće mode: "onBlur".

Zašto React Hook Form i Zod u React Nativeu

Kad su React Native forme sazrele, ustalile su se dvije stvari: React Hook Form (RHF) za lifecycle i re-render kontrolu, i Zod za shematsku validaciju. Dolazim iz web dev svijeta, i prve mjesece na mobitelu radila sam s Formikom. Čim vam se ekran ispuni s desetak polja i FlashList-om, razlike u broju re-rendera postanu vidljive golim okom na starijem Androidu (osobno sam ovaj problem uočila na Samsungu iz 2020). RHF drži formu u ne-kontroliranom modelu (u refovima) i re-render se dogodi samo kad se promijeni formState, a ne pri svakom pritisku tipke.

Zod dodaje drugu razinu: vi opisujete podatke, a validacija, TypeScript tipovi i poruke greške padaju iz iste sheme. Umjesto da dva puta pišete pravila (jednom kao rules, drugi put kao TS interface), definirate ih jednom i izvedete s z.infer. Ta ista shema može ići na server: identična validacija na klijentu i backendu, isti error kodovi, isti kontrakti. Za timove koji dijele monorepo s Next.js frontendom ovo je ozbiljna ušteda.

Konkretno: službena dokumentacija React Hook Forma potvrđuje da je RN podržan kroz Controller. Zod 4 dokumentacija je stabilna od 2026. i donosi bržu evaluaciju te bolje TypeScript inferencije.

DimenzijaReact Hook Form + ZodFormik + Yup
Re-renderi po pritisku tipkeNula (uncontrolled)Cijeli form tree
TypeScript inferencija shemeDa (z.infer)Djelomično
Veličina bundle-a~9 kB + Zod ~14 kB~13 kB + Yup ~40 kB
Deljenje sheme s backendomDa (Zod na Node/Edge)Da (Yup), rjeđe u praksi
Krivulja učenja za web devVrlo blagaBlaga
Aktivan razvoj u 2026Da (v7.80)Uspora

Postavljanje projekta i verzije za 2026

Krenimo od svježeg Expo projekta. U srpnju 2026. relevantne verzije su React Hook Form 7.80.0, Zod 4.4.3 i @hookform/resolvers 5.4.0. Resolver paket auto-detektira jeste li na Zodu 3 ili 4, tako da isti zodResolver import radi za obje verzije. Ako krećete sada, uzmite Zod 4 zbog bržih evaluacija i strožih tipova.

Instalacija:

npx create-expo-app@latest rn-forms
cd rn-forms
npx expo install react-hook-form zod @hookform/resolvers react-native-keyboard-controller

U tsconfig.json uključite strict način rada, jer Zod 4 se testira protiv TypeScripta 5.5+ i traži strogu shemu. Bez strict: true nećete dobiti benefite z.infer inferencije, tipovi će vam padati kao any.

// tsconfig.json (izvadak)
{
  "compilerOptions": {
    "strict": true,
    "jsx": "react-jsx",
    "moduleResolution": "bundler"
  }
}

Ako koristite Expo Router v6 vodič, dodajte KeyboardProvider iz react-native-keyboard-controller u root layout. Potreban je jednom po aplikaciji i najbolje ga je registrirati što više gore u stablu, iznad Stack-a.

// app/_layout.tsx
import { KeyboardProvider } from 'react-native-keyboard-controller';
import { Stack } from 'expo-router';

export default function RootLayout() {
  return (
    <KeyboardProvider>
      <Stack />
    </KeyboardProvider>
  );
}

Definiranje Zod sheme s TypeScript inferencijom

Ideja je jednostavna: shema je istina, tip se izvodi. Nikad ne pišete interface FormValues ručno, jer z.infer to radi za vas. Za registracijsku formu:

// schemas/signupSchema.ts
import { z } from 'zod';

export const signupSchema = z
  .object({
    email: z.string().email('Neispravan format e-pošte'),
    password: z
      .string()
      .min(8, 'Lozinka mora imati najmanje 8 znakova')
      .regex(/[A-Z]/, 'Lozinka mora sadržavati veliko slovo')
      .regex(/[0-9]/, 'Lozinka mora sadržavati broj'),
    confirmPassword: z.string(),
    age: z.coerce.number().int().min(18, 'Morate imati 18 godina'),
    acceptTerms: z.literal(true, {
      errorMap: () => ({ message: 'Morate prihvatiti uvjete' }),
    }),
  })
  .refine((data) => data.password === data.confirmPassword, {
    path: ['confirmPassword'],
    message: 'Lozinke se ne podudaraju',
  });

export type SignupInput  = z.input<typeof signupSchema>;   // prije coerce
export type SignupOutput = z.output<typeof signupSchema>;  // nakon coerce

Ovdje su tri detalja koja često zbune developere s weba. Prvo, z.coerce.number() ima dvije vrste tipa: input je unknown jer prima ono što stigne iz forme (u RN slučaju to je string iz TextInputa), a output je number. Zato eksplicitno tražite SignupInput kad tipizirate useForm, inače će vam TS vikati da string ne odgovara broju.

Drugo, cross-field validacije (poput «lozinke se moraju podudarati») rade se preko .refine na cijelom objektu, ne unutar polja. Jedno polje ne zna za drugo. Treće, z.literal(true) je idiomatski način na koji tražite «checkbox mora biti čekiran», čišći od .refine((v) => v === true).

Zašto koristiti Controller umjesto register

Na webu ste vjerojatno navikli na register("email") koji vraća props koje raširite na <input>. To radi jer HTML input ima native onChange event, value atribut i ref koji hvata DOM node. React Native TextInput ništa od toga nema u tom obliku: koristi onChangeText (samo string, ne event), a ref vodi na native handle, ne na DOM element. Zato u RN uvijek idete kroz Controller.

// screens/SignupScreen.tsx
import { useForm, Controller, SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { TextInput, Text, Button, View } from 'react-native';
import { signupSchema, type SignupInput, type SignupOutput } from '../schemas/signupSchema';

export default function SignupScreen() {
  const {
    control,
    handleSubmit,
    setFocus,
    formState: { errors, isSubmitting },
  } = useForm<SignupInput, unknown, SignupOutput>({
    resolver: zodResolver(signupSchema),
    defaultValues: {
      email: '',
      password: '',
      confirmPassword: '',
      age: '' as unknown as number, // string ulazi, broj izlazi
      acceptTerms: false as unknown as true,
    },
    mode: 'onBlur',
  });

  const onSubmit: SubmitHandler<SignupOutput> = async (data) => {
    // 'data' je već validiran i tipiziran kao SignupOutput
    await api.signup(data);
  };

  return (
    <View>
      <Controller
        control={control}
        name="email"
        render={({ field: { onChange, onBlur, value, ref } }) => (
          <TextInput
            ref={ref}
            value={value}
            onChangeText={onChange}
            onBlur={onBlur}
            placeholder="E-pošta"
            keyboardType="email-address"
            autoCapitalize="none"
            autoComplete="email"
            returnKeyType="next"
            onSubmitEditing={() => setFocus('password')}
          />
        )}
      />
      {errors.email && <Text>{errors.email.message}</Text>}

      {/* ostala polja... */}

      <Button
        title={isSubmitting ? 'Šaljem...' : 'Registriraj se'}
        onPress={handleSubmit(onSubmit)}
        disabled={isSubmitting}
      />
    </View>
  );
}

Tri stvari na koje pripazite. Value mora uvijek biti string (nikad undefined), pa defaultValues postavljate na prazne stringove. Inače će vam React vrištati o prijelazu iz uncontrolled u controlled. OnBlur se prosljeđuje ne samo za trigger validacije nego i da RHF zna koje polje je «dotaknuto» (touchedFields). I ref je bitan, bez njega setFocus ne može ciljano premjestiti fokus.

Kako fokusirati sljedeći TextInput u formi

Ovo je jedan od prvih «seam» problema koje web developer sretne u RN-u. Na webu tipka «Tab» prelazi na sljedeći input, ali na mobitelu se dodiruje «Next» tipka na softverskoj tipkovnici, i ništa se ne događa dok je vi ručno ne povežete. Rješenje ima dva dijela.

Prvo, na svakom TextInputu postavite returnKeyType="next" (i "done" za zadnji), i povežite onSubmitEditing s pomicanjem fokusa. Drugo, RHF-ova setFocus funkcija dolazi iz useForm i pomiče fokus na polje po imenu, koristeći ref koji ste proslijedili iz Controllera. Bez tog refa, setFocus tiho pukne.

// Uzorak za tri polja: email -> lozinka -> potvrda -> submit
<Controller
  control={control}
  name="password"
  render={({ field }) => (
    <TextInput
      {...field}
      onChangeText={field.onChange}
      secureTextEntry
      textContentType="newPassword"
      passwordRules="minlength: 8; required: upper; required: digit;"
      returnKeyType="next"
      onSubmitEditing={() => setFocus('confirmPassword')}
    />
  )}
/>

Honestly, za iOS textContentType="newPassword" uz passwordRules aktivira sistemski password manager koji predloži jaku lozinku. Velik UX plus, i način na koji forma izgleda «native» umjesto da je očito web port.

Reusable FormInput komponenta s generikama

Kopiranje istog Controller boilerplate-a za svako polje je siguran put u nekonzistentne forme. Napravite jednu generičku komponentu koja prima control, name i sve prop-ove TextInputa. TypeScript inferira dozvoljena imena iz same sheme.

// components/FormInput.tsx
import { forwardRef, useImperativeHandle, useRef } from 'react';
import {
  Text,
  TextInput,
  TextInputProps,
  View,
  StyleSheet,
} from 'react-native';
import {
  Control,
  Controller,
  FieldPath,
  FieldValues,
} from 'react-hook-form';

type Props<T extends FieldValues> = Omit<TextInputProps, 'value' | 'onChangeText'> & {
  control: Control<T>;
  name: FieldPath<T>;
  label: string;
};

export function FormInput<T extends FieldValues>({
  control,
  name,
  label,
  ...inputProps
}: Props<T>) {
  return (
    <Controller
      control={control}
      name={name}
      render={({
        field: { onChange, onBlur, value, ref },
        fieldState: { error },
      }) => (
        <View style={styles.wrapper}>
          <Text style={styles.label}>{label}</Text>
          <TextInput
            ref={ref}
            value={value ?? ''}
            onChangeText={onChange}
            onBlur={onBlur}
            style={[styles.input, error && styles.inputError]}
            {...inputProps}
          />
          {error && <Text style={styles.error}>{error.message}</Text>}
        </View>
      )}
    />
  );
}

const styles = StyleSheet.create({
  wrapper: { marginBottom: 12 },
  label: { fontSize: 14, marginBottom: 4 },
  input: {
    borderWidth: 1,
    borderColor: '#d1d5db',
    borderRadius: 8,
    padding: 12,
  },
  inputError: { borderColor: '#dc2626' },
  error: { color: '#dc2626', fontSize: 12, marginTop: 4 },
});

Trik je u FieldPath<T> tipu. On ograničava name na string-literale poznatih polja u shemi, uključujući ugniježđena imena poput "address.city". Ako se ime polja promijeni, TS vam odmah javi na svakoj poziciji gdje se komponenta koristi. Isti obrazac možete replicirati za FormSwitch, FormPicker i FormDatePicker.

Rukovanje tipkovnicom: KeyboardAvoidingView i keyboard-controller

Ovaj dio je klasična web-to-mobile spojnica. Na webu tipkovnica ne postoji kao layout problem; u RN-u je to prva stvar koja pukne prezentaciju forme. Postoje dva alata: ugrađeni KeyboardAvoidingView i react-native-keyboard-controller.

KeyboardAvoidingView je dovoljan za jednostavne, kratke forme koje stanu u jedan ekran. Ključne razlike između platformi: na iOS-u koristite behavior="padding", na Androidu često dovoljno da samo dodate KeyboardAvoidingView bez behavior-a (jer window softInputMode odradi svoje). Za sve složenije scenarije (scrollane forme s više polja, sticky button-e iznad tipkovnice, chat ulaze) koristite react-native-keyboard-controller.

// screens/SignupScreen.tsx (dio s layoutom)
import { KeyboardAwareScrollView, KeyboardStickyView } from 'react-native-keyboard-controller';

return (
  <KeyboardAwareScrollView
    bottomOffset={20}
    contentContainerStyle={{ padding: 16 }}
  >
    <FormInput control={control} name="email" label="E-pošta" />
    <FormInput control={control} name="password" label="Lozinka" secureTextEntry />
    {/* ... */}

    <KeyboardStickyView offset={{ closed: 0, opened: 0 }}>
      <Button title="Registriraj se" onPress={handleSubmit(onSubmit)} />
    </KeyboardStickyView>
  </KeyboardAwareScrollView>
);

Bitno pravilo: nikad ne omatajte FlashList ili FlatList u KeyboardAwareScrollView. To se lomi (dobit ćete duple scrollere) i ubija performanse recikliranja. Umjesto toga, koristite renderScrollComponent prop na listi kako je opisano u Expo dokumentaciji za rukovanje tipkovnicom. I ne miješajte KeyboardAvoidingView s KeyboardAwareScrollView; birajte jedno prema slučaju upotrebe.

Async validacija i server greške

Do sada je sve bilo sinkrono. U pravoj aplikaciji trebate provjeriti je li username slobodan, je li email već registriran, ili validirati kupon protiv API-ja. Zod to podržava kroz async .refine:

// Asinkrona provjera slobodnog usernama
const usernameSchema = z
  .string()
  .min(3)
  .refine(
    async (value) => {
      const res = await fetch(`/api/username-available?u=${value}`);
      const { available } = await res.json();
      return available;
    },
    { message: 'Korisničko ime je zauzeto' }
  );

Da bi RHF pokrenuo asinkronu validaciju, promijenite mode na "onBlur" ili "onChange". Za skup polja koja tucaju API neka bude "onBlur", jer ne želite debounce-ati request na svaki keystroke.

Server greške koje stižu tek nakon submit-a mapirate ručno kroz setError. Pretpostavimo da backend vraća { field: "email", message: "Već registriran" }:

const onSubmit = async (data: SignupOutput) => {
  try {
    await api.signup(data);
  } catch (err) {
    if (err instanceof ApiValidationError) {
      err.issues.forEach(({ field, message }) => {
        setError(field as FieldPath<SignupInput>, {
          type: 'server',
          message,
        });
      });
      // fokusirajte prvo neispravno polje
      setFocus(err.issues[0].field as FieldPath<SignupInput>);
    }
  }
};

Ovaj obrazac koristim u svakom projektu. U mom zadnjem projektu upravo je ovo mapiranje spasilo tjedan dana dorada. Server je izvor istine za jedinstvenost, ali korisnik dobije istu vizualnu poruku greške kao za sinkronu validaciju, na istom mjestu, s istim stilom. Ako radite React Native autentifikaciju s Clerkom ili Supabaseom, mapiranje njihovih grešaka na polja je jedan od najvažnijih detalja za UX.

Formik ili React Hook Form u 2026

Ovo je najčešće «People Also Ask» pitanje za RN forme. Formik je pouzdan i još uvijek u produkciji na milijardu ekrana, ali njegov razvoj se usporio, a arhitekturno je controlled. Svaki keystroke re-renderira cijelo form stablo. Za forme s 3-4 polja to ne primijetite; za onboarding tok s 15 polja i FlashListom razlike su vidljive.

RHF ima uncontrolled model, manji bundle uz Zod, i deklarativnu shemu koja se dijeli s backendom. Za nove projekte u 2026. moj savjet je jasan: RHF + Zod. Za postojeće Formik projekte, migracija nije hitna. Radite je ekran po ekran kad radite druge izmjene na tom ekranu, ne kao big-bang refactor. Testovi ostaju isti (i dalje pišete E2E s Maestrom ili Detoxom u React Nativeu, samo što će vam s RHF-om trebati manje waitFor baraž na re-rendere).

Jedini scenariji gdje bih preporučila da ostanete na Formiku: postojeći kod s tone custom fielda i timovi koji imaju svoj wrapping layer, jer troškovi selidbe premašuju benefite. U svakom drugom slučaju, RHF + Zod je defaultni izbor.

Česta pitanja

Koja je razlika između Controller i register u React Nativeu?

register vraća props koje se raspačuju na HTML input i oslanja se na ref koji hvata DOM node, pa ne radi s TextInputom jer RN nema DOM. Controller je most: on drži polje pod kontrolom RHF-a i eksplicitno vam daje onChange, onBlur, value i ref koje ručno vežete na TextInput. U React Nativeu uvijek koristite Controller.

Trebam li KeyboardAvoidingView uz react-native-keyboard-controller?

Ne. Odaberite jedno prema use case-u: KeyboardAvoidingView za fiksne kratke forme, KeyboardAwareScrollView iz react-native-keyboard-controller za scrollane forme s više polja. Miješanje uzrokuje «jumpy» ponašanje jer se dva sustava boriju za istu poziciju.

Kako postaviti Zod shemu za broj koji dolazi iz TextInputa kao string?

Koristite z.coerce.number(). On prihvaća bilo koji unknown ulaz i izbaci ga kao number. Zbog toga z.infer ima dva tipa: z.input (prije coerce, koristite ga za useForm<Input>) i z.output (poslije coerce, koristite ga za SubmitHandler<Output>). Bez tog razdvajanja TS će prigovarati na tipove.

Radi li React Hook Form s Expo Goom?

Da, RHF i Zod su čisto JS i rade u Expo Go bez izmjena. react-native-keyboard-controller zahtijeva native modul i mora ići kroz EAS Build ili development build. Ako testirate u Expo Go, koristite KeyboardAvoidingView dok ne pređete.

Kako prikazati server greške pored konkretnog polja?

U catch bloku submit funkcije zovnite setError(fieldName, { type: 'server', message }) za svako polje koje backend prijavi kao neispravno. Ta greška ide u istu formState.errors mapu kao i sinkrone Zod greške, tako da vaš FormInput komponentu bez izmjena renderira poruku na istom mjestu.

Anita Iyer
O Autoru Anita Iyer

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