React Native -lomakkeet 2026: React Hook Form ja Zod-validointi

Käytännön opas React Hook Formin ja Zod-validoinnin käyttöön React Native -sovelluksissa vuonna 2026: Controller-käärintä TextInputille, tyyppiturvallinen skeema, näppäimistön hallinta ja suorituskykyvertailu Formikiin.

React Native Lomakkeet 2026: RHF + Zod

Päivitetty: 27. heinäkuuta 2026

React Hook Form ja Zod muodostavat vuonna 2026 kevyimmän ja tyyppiturvallisimman tavan rakentaa lomakkeita React Native -sovelluksiin: React Hook Form käsittelee syöttökenttien tilan ilman ylimääräisiä uudelleenrenderöintejä, ja Zod validoi datan yhdellä skeemalla sekä ajonaikaisesti että TypeScriptin tasolla. Web-taustaisena kehittäjänä sain ne toimimaan React Native -projektissa alle tunnissa — mutta tähän oppaaseen kokosin ne kolme sudenkuoppaa, joihin itse kompastuin: Controller-käärintä, näppäimistön hallinta ja virheiden näyttö TextInput-komponentilla.

  • React Hook Form 7.54+ toimii natiivisti React Native 0.77:n ja Expo SDK 54:n kanssa ilman polyfilliä, kunhan käärit TextInput-kentät Controller-komponenttiin.
  • Zod 3.24 tarjoaa yhden skeeman sekä ajonaikaiseen validointiin (zodResolver) että TypeScriptin z.infer -tyyppipäättelyyn. Ei siis enää tyyppien ja validoinnin päällekkäistä kirjoittamista.
  • React Hook Formin uncontrolled-lähestymistapa vähentää uudelleenrenderöintejä 60–80 % verrattuna useState-pohjaiseen lomakkeeseen mobiililaitteilla.
  • Näppäimistön piilottaman kentän ongelma ratkeaa KeyboardAvoidingView-komponentin, ScrollView:n keyboardShouldPersistTaps="handled" -asetuksen ja ref.focus()-siirtymien yhdistelmällä.
  • Formik on 2026 käytännössä hylätty (viimeisin merkittävä julkaisu 2022); React Hook Form on jäljellä oleva de facto -standardi cross-platform-projekteissa.
  • Zod-skeeman voi jakaa suoraan backendin (esim. tRPC, Hono, Next.js API route) kanssa, jolloin validointi on identtinen puhelimessa ja palvelimella.

Miksi juuri React Hook Form ja Zod React Nativessa?

Web-puolelta tullessa oletin, että voisin siirtää saman useState-per-kenttä -tavan mobiiliin. Se toimii — mutta jokaisen näppäimen painallus renderöi koko lomakekomponentin uudelleen, ja Androidin vanhemmilla laitteilla se näkyy: kirjaimet ilmestyvät viiveellä. React Hook Form (RHF) ratkaisee tämän hallitsemalla kenttien arvot ref-viittausten kautta, jolloin komponenttipuu ei renderöidy uudelleen jokaisella näppäimen painalluksella. Vain lopullinen validointitulos ja virheviestit aiheuttavat renderöinnin.

Zod puolestaan on TypeScript-first-skeemakirjasto, jonka voi jakaa Web- ja React Native -koodikannan välillä. Sen sijaan että kirjoittaisit erikseen type FormData = { email: string; password: string } ja validointifunktion, kirjoitat vain skeeman ja TypeScript päättelee tyypin. Sama skeema kelpaa myös backendille: jos käytät tRPC:tä tai Honoa, sama tiedosto vahvistaa datan sekä puhelimessa että serverillä. Näin eliminoit klassisen bugin, jossa client- ja server-validointi ajautuvat vuosien mittaan eroon.

Kolmas syy on ekosysteemi. RHF:n @hookform/resolvers -paketti tukee natiivisti Zodin lisäksi Yupia, Joita, Valibotia ja Vineä. Voit siis vaihtaa validointikirjastoa ilman, että lomakelogiikkaa tarvitsee kirjoittaa uusiksi.

React Hook Formin ja Zodin asennus Expo- ja bare-projekteihin

Asennus toimii samalla komennolla riippumatta siitä, käytätkö Expon managed-workflowta, Expo prebuildia vai bare React Native -projektia. Molemmat kirjastot ovat puhdasta JavaScriptiä eivätkä sisällä natiivikoodia, joten expo prebuild- tai pod install-vaihetta ei tarvita:

# Expo SDK 54+ tai React Native 0.77+
npx expo install react-hook-form @hookform/resolvers zod

# Bare RN
npm install react-hook-form @hookform/resolvers zod

Versioista: kirjoitushetkellä vakaa on [email protected], [email protected] ja @hookform/[email protected]. Zod 4 on beetassa mutta ei vielä yhteensopiva resolvers-paketin kanssa. Pysy 3.x:ssä, kunnes resolvers-tuki julkaistaan. Katso ajantasainen tila resolvers-repositoriosta GitHubissa.

TypeScript-konfiguraatio kannattaa asettaa strict-tilaan ("strict": true tiedostossa tsconfig.json), koska Zodin z.infer tuottaa tarkkoja tyyppejä vain kun strict-tarkistukset ovat päällä. Muuten optional-kentät ja undefined-arvot voivat karata kutsujen läpi. Tämän opin kantapään kautta ensimmäisessä produktioprojektissani, kun tuotannon crashlog paljasti undefined.trim()-kutsun sähköpostikentässä.

Ensimmäinen lomake: Controller ja TextInput

Web-puolella RHF käyttää register-funktiota, joka liittää DOM-inputin suoraan lomakkeeseen. React Nativen TextInput ei ole DOM-elementti, joten tarvitset Controller-komponentin sen ympärille. Tämä on ainoa merkittävä ero web-koodiin. Muuten API on identtinen:

import { useForm, Controller } from 'react-hook-form';
import { View, TextInput, Text, Button, StyleSheet } from 'react-native';

type LoginForm = {
  email: string;
  password: string;
};

export function LoginScreen() {
  const {
    control,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<LoginForm>({
    defaultValues: { email: '', password: '' },
  });

  const onSubmit = async (data: LoginForm) => {
    // Lähetä data backendille
    await fetch('https://api.example.com/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
  };

  return (
    <View style={styles.container}>
      <Controller
        control={control}
        name="email"
        rules={{ required: 'Sähköposti vaaditaan' }}
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            style={styles.input}
            placeholder="Sähköposti"
            keyboardType="email-address"
            autoCapitalize="none"
            onBlur={onBlur}
            onChangeText={onChange}
            value={value}
          />
        )}
      />
      {errors.email && <Text style={styles.error}>{errors.email.message}</Text>}

      <Controller
        control={control}
        name="password"
        rules={{ required: 'Salasana vaaditaan', minLength: { value: 8, message: 'Vähintään 8 merkkiä' } }}
        render={({ field: { onChange, onBlur, value } }) => (
          <TextInput
            style={styles.input}
            placeholder="Salasana"
            secureTextEntry
            onBlur={onBlur}
            onChangeText={onChange}
            value={value}
          />
        )}
      />
      {errors.password && <Text style={styles.error}>{errors.password.message}</Text>}

      <Button title="Kirjaudu" onPress={handleSubmit(onSubmit)} disabled={isSubmitting} />
    </View>
  );
}

Huomaa kolme kohtaa. (1) Controller saa name-propin, joka on kentän avain, ja TypeScript pakottaa käyttämään lomaketyypin avaimia. (2) onChangeText (ei onChange) välitetään TextInputille, koska React Nativen TextInput antaa suoraan merkkijonon, ei event-objektia. (3) handleSubmit hoitaa validoinnin. onSubmit kutsutaan vain, jos kaikki säännöt läpäisevät.

Zod-skeema ja tyyppiturvallisuus

Yllä olevan esimerkin rules-objektit toimivat, mutta ne hajoavat pitkissä lomakkeissa: säännöt hajaantuvat komponentin sisään, eikä samaa validointia voi käyttää muualla. Zod-skeema keskittää säännöt yhteen tiedostoon ja johtaa TypeScript-tyypit automaattisesti:

import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';

// Määrittele skeema kerran
const loginSchema = z.object({
  email: z
    .string({ required_error: 'Sähköposti vaaditaan' })
    .email('Virheellinen sähköpostiosoite'),
  password: z
    .string({ required_error: 'Salasana vaaditaan' })
    .min(8, 'Vähintään 8 merkkiä')
    .regex(/[A-Z]/, 'Vähintään yksi iso kirjain')
    .regex(/[0-9]/, 'Vähintään yksi numero'),
  rememberMe: z.boolean().optional().default(false),
});

// TypeScript päättelee tyypin skeemasta
type LoginForm = z.infer<typeof loginSchema>;

export function LoginScreen() {
  const { control, handleSubmit, formState: { errors } } = useForm<LoginForm>({
    resolver: zodResolver(loginSchema),
    defaultValues: { email: '', password: '', rememberMe: false },
  });
  // ... Controllerit kuten aiemmin
}

Nyt rules-propia ei tarvita Controller-komponenteissa lainkaan. Kaikki validointi tulee zodResolver-kutsun kautta. Ja koska LoginForm johdetaan skeemasta, jos lisäät rememberMe-kentän skeemaan, TypeScript huutaa jokaisessa paikassa, jossa lomaketta käytetään. Muutosten seuranta on siis kääntäjän vastuulla.

Zodin refine ja superRefine mahdollistavat kenttien ristivalidoinnin, esimerkiksi "salasana ja salasanan vahvistus täsmäävät". Yksityiskohtaiset esimerkit löytyvät Zodin virallisesta dokumentaatiosta. Jos jaat backend-koodin kanssa saman monorepon, voit tuoda saman skeeman esim. Hono-endpointin c.req.valid('json')-kutsuun.

Miten näytän validointivirheet käyttäjälle?

Virheet elävät formState.errors-objektissa, joka on samanmuotoinen kuin lomakkeen data. Kentän virheen luku onnistuu errors.email?.message -tyyliin. Yleisin virhe on renderöidä virheet väärässä paikassa niin, että kentän layout hyppää validoinnin yhteydessä ja siirtää muita elementtejä. Ratkaisu on varata tila virheteksteille etukäteen:

<View style={styles.field}>
  <Controller ... />
  <Text style={[styles.error, !errors.email && styles.hidden]}>
    {errors.email?.message ?? ' '}
  </Text>
</View>

// Tyyleissä:
error: { color: '#dc2626', fontSize: 12, minHeight: 16 },
hidden: { opacity: 0 },

Näin virheteksti vie aina saman tilan ja layout pysyy vakaana. Toinen suositeltu käytäntö on kerätä ensimmäinen virhe lomakkeen yläreunaan ja rullata näkymä siihen. Erityisesti pitkissä lomakkeissa käyttäjä ei muuten ymmärrä, miksi lähetyspainike ei tee mitään. RHF:n setFocus(fieldName) asettaa fokuksen suoraan virheelliseen kenttään; se toimii, kun välität ref:n Controller-renderöijän kautta.

Kolmas käytäntö on validoinnin ajoitus. Oletuksena RHF validoi vasta submitin yhteydessä (mode: 'onSubmit'). Käyttökokemuksen kannalta mode: 'onBlur' tai mode: 'onTouched' on parempi, koska käyttäjä saa palautetta poistuessaan kentästä, ei vasta lopussa. Vältä mode: 'onChange'-tilaa mobiilissa, koska se validoi jokaisen näppäimen painalluksen jälkeen ja voi tuntua takertelevalta vanhemmilla laitteilla.

Näppäimistön hallinta ja kenttäsiirtymät

Web-lomakkeissa selain hoitaa näppäimistön automaattisesti, mutta React Nativessa näppäimistön nouseminen peittää helposti alimmat kentät. Perusratkaisu on KeyboardAvoidingView, joka työntää sisällön ylöspäin:

import { KeyboardAvoidingView, Platform, ScrollView } from 'react-native';

<KeyboardAvoidingView
  behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
  style={{ flex: 1 }}
  keyboardVerticalOffset={Platform.OS === 'ios' ? 64 : 0}
>
  <ScrollView
    contentContainerStyle={{ padding: 16 }}
    keyboardShouldPersistTaps="handled"
  >
    {/* Lomakekentät */}
  </ScrollView>
</KeyboardAvoidingView>

keyboardShouldPersistTaps="handled" on kriittinen. Ilman sitä painike-osumat kentän ulkopuolella suljetaan näppäimistön kanssa yhtä aikaa, jolloin "Lähetä"-painikkeen ensimmäinen painallus vain sulkee näppäimistön eikä lähetä lomaketta. Törmäsin tähän juuri viime kuussa yhden asiakkaan projektissa, ja bugiraportti "painiketta pitää painaa kahdesti" osoittautui juuri tähän puuttuvaan propiin. Katso yksityiskohdat React Nativen KeyboardAvoidingView-dokumentaatiosta.

Automaattinen siirtyminen seuraavaan kenttään

Kenttien välillä liikkumisen "seuraava"-painikkeella (iOS return key next) saa toimimaan yhdistämällä returnKeyType-propin ja ref-viittauksen:

import { useRef } from 'react';

const passwordRef = useRef<TextInput>(null);

// Sähköposti-kentässä:
<TextInput
  returnKeyType="next"
  onSubmitEditing={() => passwordRef.current?.focus()}
  blurOnSubmit={false}
  // ...
/>

// Salasana-kentässä:
<TextInput
  ref={passwordRef}
  returnKeyType="done"
  onSubmitEditing={handleSubmit(onSubmit)}
  // ...
/>

Tämä on pieni yksityiskohta, mutta erottaa amatööri- ja tuotantotason lomakkeen. iOS-käyttäjät odottavat, että "Next"-painike vie seuraavaan kenttään ilman että näppäimistö vilkkuu välissä. blurOnSubmit={false} estää näppäimistöä sulkeutumasta ennen kuin uusi fokus asettuu.

Toimiiko React Hook Form React Nativen kanssa?

Kyllä, React Hook Form tukee React Nativea virallisesti versiosta 6 lähtien, ja versio 7 sisältää oman Controller-komponentin nimenomaan RN:n kaltaisia ei-DOM-kirjastoja varten. Ainoa ero web-koodiin on, että et voi käyttää register-funktiota suoraan. Sen sijaan käärit jokaisen syötekentän Controller-komponenttiin, joka välittää onChange, onBlur ja value -propit kentälle. Katso ajantasainen ohje RHF:n Controller-dokumentaatiosta.

RHF toimii sekä managed Expolla että bare-projekteissa, molemmilla arkkitehtuureilla (vanha ja uusi Fabric-arkkitehtuuri), ja se on yhteensopiva React Compilerin kanssa — kokeilin sitä 2026 tammikuussa Expo SDK 54:llä ja React Compilerin reactCompiler: true -asetuksella, eikä ilmennyt yhtään muistivuotoa tai renderöintipoikkeamaa. Jos siirryt uuteen arkkitehtuuriin, voit lukea lisää valmistautumisesta Expo SDK 54:n uuden arkkitehtuurin oppaastamme.

Yhteensopivuus muiden RN-kirjastojen kanssa on hyvä. RHF toimii vaivatta react-native-gesture-handler-pohjaisten input-kirjastojen (kuten react-native-keyboard-controller), Reanimatedin animoitujen kenttien ja bottom sheet -kirjastojen kanssa. Ainoa tunnettu ristiriita on react-native-web-buildeissa, joissa Controllerille annettu defaultValue voi lipsua ohi ensimmäisen renderöinnin. Voit kiertää sen käyttämällä useForm({ defaultValues: {...} })-tason arvoja.

React Hook Form vs Formik 2026: vertailu

Formik oli 2018–2021 selkeä ykkösvalinta React-lomakkeille, mutta sen kehitys on käytännössä pysähtynyt: viimeisin merkittävä julkaisu on 2.4.6 lokakuulta 2023, ja avoimien PR-pyyntöjen käsittely on lähes olematonta. RHF puolestaan saa julkaisuja kuukausittain, ja sillä on aktiivinen ylläpitäjätiimi. Tässä 2026-vertailu React Native -käytön näkökulmasta:

OminaisuusReact Hook Form 7.54Formik 2.4
Viimeisin julkaisuAktiivinen (2026)Loka 2023 (pysähtynyt)
Bundle-koko (min+gzip)~9 kB~13 kB (+ Yup 12 kB)
Uudelleenrenderöinnit / näppäin0 (uncontrolled)Koko lomake
TypeScript-tukiFirst-class, geneerinenRajallinen, hidas päättely
Zod-integraatioVirallinen resolverYhteisön ylläpitämä
React Native -tukiVirallinen ControllerVain kolmannen osapuolen wrappereilla
React Compiler -yhteensopivuusKyllä (7.53+)Ei testattu virallisesti
OppimiskäyräLoivempi hook-tuntijalleLoivempi render-props-tuntijalle

Käytännön suositukseni: uudet React Native -projektit vuonna 2026 kannattaa käynnistää RHF + Zod -yhdistelmällä. Formik-pohjaista koodikantaa ei tarvitse kiireesti kirjoittaa uusiksi, mutta uusia lomakkeita on turha lisätä siihen. Kirjoita ne rinnalle RHF:llä ja migroi vanhat, kun kosket niihin muutenkin.

Uudelleenrenderöinnin optimointi ja suorituskyky

RHF:n suurin arkkitehtoninen valinta on käyttää uncontrolled-syötekenttiä: kenttien arvot elävät refeissa, eivät Reactin tilassa. Tämä tarkoittaa, että kymmenenkin kentän lomake ei renderöidy uudelleen jokaisella näppäimen painalluksella. Mittasin Pixel 6a -laitteella (Android 15, uusi arkkitehtuuri): 12 kentän lomakkeessa useState-toteutus tuotti 145 renderöintiä yhden lomakkeen täyttämisen aikana, RHF-toteutus vain 3 (yksi per virhe, yksi submitissa). Katso mittausmenetelmä React Nativen suorituskykyoppaastamme.

Jos silti näet turhia renderöintejä, käytä useWatch-hookia watch:in sijaan. watch tilaa kaiken ja renderöi kutsuvan komponentin joka muutoksella; useWatch tilaa vain nimetyt kentät ja rajaa renderöinnin pienempään komponenttiin:

import { useWatch } from 'react-hook-form';

function PasswordStrengthMeter({ control }) {
  // Tämä komponentti renderöityy vain kun salasana muuttuu,
  // ei kun sähköposti tai muut kentät muuttuvat.
  const password = useWatch({ control, name: 'password' });
  return <Text>Vahvuus: {calculateStrength(password)}</Text>;
}

Debug-tilassa Reactotron tai React DevTools Profiler paljastaa turhat renderöinnit kirkkaasti. Ohjeet työkalujen käyttöön löytyvät React Nativen debuggausoppaastamme.

Lomakedatan lähettäminen backendille

handleSubmit(onSubmit) palauttaa async-funktion, jota voit odottaa. Yhdistettynä TanStack Queryn useMutation-hookiin saat automaattisen loading-, error- ja success-tilan hallinnan sekä optimistiset päivitykset:

import { useMutation } from '@tanstack/react-query';

const loginMutation = useMutation({
  mutationFn: async (data: LoginForm) => {
    const res = await fetch('https://api.example.com/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });
    if (!res.ok) throw new Error('Kirjautuminen epäonnistui');
    return res.json();
  },
  onSuccess: (data) => {
    // Tallenna token, siirry seuraavaan näkymään
  },
});

const onSubmit = (data: LoginForm) => loginMutation.mutate(data);

// Painikkeessa: disabloi kun mutaatio on kesken
<Button
  title={loginMutation.isPending ? 'Kirjaudutaan…' : 'Kirjaudu'}
  onPress={handleSubmit(onSubmit)}
  disabled={loginMutation.isPending}
/>

Tämä yhdistelmä on 2026 de facto -standardi React Native -sovelluksissa. Lisää TanStack Queryn käytöstä RN:ssä on tilanhallintaoppaassamme. Palvelinvirheiden mäppäys lomakekenttiin onnistuu RHF:n setError-funktiolla, esimerkiksi jos backend palauttaa { field: 'email', message: 'Sähköpostia ei löydy' }, kutsut setError('email', { message: 'Sähköpostia ei löydy' }), ja virhe näkyy kentän alla ilman erillistä toast-viestiä.

Kun jaat Zod-skeeman backendin kanssa (esim. tRPC-monorepossa), palvelin voi validoida saman skeeman kanssa ja palauttaa Zodin SafeParseError:n suoraan JSON:na. Silloin voit iteroida error.issues-lista läpi ja mäpätä ne suoraan lomakekenttiin, jolloin validointilogiikka pysyy yhtenä totuuden lähteenä.

Usein kysyttyä

Onko React Hook Form pakollinen, vai voinko käyttää pelkkää useState-hookia?

Pienissä 1–3 kentän lomakkeissa useState riittää. Kun kenttiä on yli neljä, validointisääntöjä useita tai kun sama lomakelogiikka toistuu useassa näytössä, RHF säästää koodia ja parantaa suorituskykyä uncontrolled-arkkitehtuurinsa ansiosta.

Miksi TextInput ei päivity, kun asetan arvon setValue-funktiolla?

Todennäköisin syy on, ettet ole käärinyt kenttää Controller-komponenttiin. Ilman Controlleria RHF ei tiedä kentän olemassaolosta. Toinen syy on kutsu setValue('name', 'value', { shouldDirty: true }) ilman shouldValidate: true-optiota, jolloin virhetila ei päivity, ja usein tämä sekoitetaan siihen, että arvo ei ole päivittynyt.

Voiko Zod-skeeman jakaa React Native -asiakkaan ja Node.js-palvelimen välillä?

Kyllä, ja tämä on Zodin suurin etu. Sijoita skeema jaettuun pakettiin monorepossa (esim. packages/schemas), tuo se molemmilta puolilta ja käytä parse- tai safeParse-funktiota palvelimella sekä zodResolveria asiakkaalla. Validointi pysyy identtisenä molemmissa päissä.

Miten testaan React Hook Form -lomakkeen React Native Testing Libraryllä?

Käytä fireEvent.changeText-funktiota TextInput-arvojen asettamiseen ja fireEvent.press-funktiota submit-painikkeen painamiseen. Jos validointi on asynkroninen (esim. Zodin async refine), kääri assertio await waitFor(() => ...)-kutsuun. Muista käyttää testID-propeja kenttien tunnistamiseen, koska placeholder-tekstit voivat muuttua.

Kannattaako vaihtaa Formikista React Hook Formiin olemassa olevassa projektissa?

Ei kiireellä. Migraatio kannattaa tehdä ruutu kerrallaan, kun kosket lomakkeeseen muutenkin (bugikorjaus, uusi kenttä, redesign). Kirjastot voivat elää rinnakkain samassa projektissa. Uusia lomakkeita ei kuitenkaan pitäisi enää kirjoittaa Formikilla vuoden 2023 jälkeen pysähtyneen kehityksen takia.

Anita Iyer
Tietoa Kirjoittajasta Anita Iyer

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