Skip to content
All posts

11 min read

React Native form validation: react-hook-form + Zod

The honest answer is react-hook-form with a Zod resolver. A real sign-up form: per-field errors, focus management, and animated error states.

For form validation in React Native in 2026, use react-hook-form with a Zod schema through @hookform/resolvers. It gives you one schema that types your form and validates it, per-field errors without re-rendering the whole screen, and the two hooks you need for the parts everyone forgets: moving focus to the first invalid field, and animating the error text so the layout does not jump.

Below is a real sign-up form in the pieces you would build it in: the schema, the form hook, one field component used four times, the keyboard and focus plumbing that makes it feel native, the error animation that stops the form shuddering, and an honest read on when Formik or plain useState is still the right call. Everything targets React Native 0.8x on Expo SDK 54 or newer with react-native-reanimated v4.

Why react-hook-form plus Zod, and not the alternatives?

Three approaches are alive in React Native codebases right now. They are not equally good, but they are not equally bad either.

Hand-rolled useState Formik + Yup react-hook-form + Zod
Best for 1 to 3 fields, no cross-field rules Codebases already on Formik Everything else
Re-renders per keystroke Whole screen, unless you split components by hand Whole form: state lives in one context Only the field that changed
Schema reuse on the server None. You write the rules twice Yup schema, if your API is JS Same Zod schema your API route parses with
Cross-field rules Manual Yup.ref .refine() with a path
Focus and submit plumbing You write all of it You write all of it setFocus plus the invalid callback
Project health Yours forever Long stretches with no release Actively released

The re-render row is the one that bites on mobile. Formik keeps form state in a single React context, so every keystroke re-renders every field. On a web form you never notice. On a phone, with a shimmer on the focused input and a spring on the submit button, you are burning frame budget on typing. react-hook-form subscribes per field, so typing in the email input re-renders the email input.

The controlled caveat

On the web, react-hook-form is famously uncontrolled. In React Native you use Controller or useController, which is controlled again. You still win, because the subscription is scoped to one field instead of the whole tree, but the "zero re-render" headline from the web docs does not apply verbatim.

What do you install?

npx expo install react-hook-form zod @hookform/resolvers \
  react-native-reanimated react-native-keyboard-controller expo-haptics

Keep @hookform/resolvers current: the Zod 4 resolver landed in a major version of that package, and an old resolver against a new Zod is the most common "my errors are undefined" bug. One build note: react-native-keyboard-controller is a native module that is not bundled into Expo Go, so this form needs a development build. Reanimated is a native module too, but it ships inside Expo Go, so the animation parts on their own are fine there.

How do you write the schema?

One schema, one source of truth: it produces the TypeScript type and the runtime rules, and if your backend is TypeScript it parses the request with the same file.

import { z } from 'zod';

export const SignUpSchema = z
  .object({
    name: z.string().trim().min(2, 'Your name needs at least 2 characters'),
    email: z.email('That does not look like an email address'),
    password: z
      .string()
      .min(8, 'Use at least 8 characters')
      .regex(/[0-9]/, 'Include at least one number'),
    confirm: z.string(),
  })
  .refine((values) => values.password === values.confirm, {
    message: 'Passwords do not match',
    path: ['confirm'],
  });

export type SignUpValues = z.infer<typeof SignUpSchema>;

Two things worth calling out. z.email() is the Zod 4 spelling; on Zod 3 it is z.string().email(). And the path in .refine() is not optional in practice: without it the "passwords do not match" error lands on the form root instead of under the confirm field, and you will spend twenty minutes wondering why nothing renders.

Write the messages as the sentence the user reads. Not "Invalid input". Not "String must contain at least 8 character(s)". The schema is the copy.

How do you wire the form?

Put the form in its own hook. The screen stays a layout file, and the schema, the submit handler and the server-error path live in one place you can test.

// useSignUpForm.ts
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { SignUpSchema, type SignUpValues } from './schema';
import { signUp } from './api';

export const FIELD_ORDER = ['name', 'email', 'password', 'confirm'] as const;

export function useSignUpForm(onSignedUp: () => void) {
  const form = useForm<SignUpValues>({
    resolver: zodResolver(SignUpSchema),
    mode: 'onTouched',
    reValidateMode: 'onChange',
    defaultValues: { name: '', email: '', password: '', confirm: '' },
  });
  const { setError, setFocus } = form;

  const onValid = async (values: SignUpValues) => {
    try {
      const res = await signUp(values);

      if (res.status === 409) {
        setError('email', { type: 'server', message: 'That email is already taken' });
        setFocus('email');
        return;
      }

      onSignedUp();
    } catch {
      setError('root', { message: 'Could not reach the server. Try again.' });
    }
  };

  return { ...form, submit: form.handleSubmit(onValid) };
}

The try/catch is not decoration. handleSubmit keeps isSubmitting true for as long as your handler is pending, so an unhandled network throw is how you end up with a button that stays disabled forever. Catch it, put the message on root, render formState.errors.root?.message above the button.

mode: 'onTouched' is the setting that makes a form feel polite. It waits for a blur before it complains, then switches to live validation for that field, so the message clears the instant the user fixes it. Validating on every keystroke tells someone their email is invalid while they are still typing the letter a.

setError is how server failures join the same system. A 409 from your API becomes a normal field error: same component, same animation, same focus behaviour. No second error-display path.

How do you render a field?

One Field component, used four times. useController gives you the value, the change handler, the blur handler, and a ref that react-hook-form can focus later.

// Field.tsx
import { useController, type Control } from 'react-hook-form';
import { StyleSheet, TextInput, View, Text, type TextInputProps } from 'react-native';
import { FieldError } from './FieldError'; // built further down
import type { SignUpValues } from './schema';

type FieldProps = TextInputProps & {
  control: Control<SignUpValues>;
  name: keyof SignUpValues;
  label: string;
};

export function Field({ control, name, label, ...inputProps }: FieldProps) {
  const { field, fieldState } = useController({ control, name });
  const error = fieldState.error?.message;

  return (
    <View style={styles.field}>
      <Text style={styles.label}>{label}</Text>
      <TextInput
        {...inputProps}
        ref={field.ref}
        value={field.value}
        onChangeText={field.onChange}
        onBlur={field.onBlur}
        accessibilityLabel={label}
        style={[styles.input, error && styles.inputInvalid, inputProps.style]}
      />
      <FieldError message={error} />
    </View>
  );
}

const styles = StyleSheet.create({
  field: { gap: 6 },
  label: { fontSize: 13, fontWeight: '600', color: '#3F3F46' },
  input: {
    height: 48,
    borderWidth: 1,
    borderColor: '#D4D4D8',
    borderRadius: 12,
    paddingHorizontal: 14,
    fontSize: 16,
  },
  inputInvalid: { borderColor: '#D92D20' },
});

Spread inputProps first. Because FieldProps extends TextInputProps, a caller can pass value or onChangeText, and if the spread came last it would quietly unhook the controller and you would have an input that never updates. The one prop worth merging rather than winning outright is style, which is why it is composed at the end of the array so the invalid border still lands.

Then the per-field native props that decide whether the form feels like an app or like a web page in a shell:

// one of these per key in FIELD_ORDER, rendered by the screen further down
<Field
  control={control}
  name="email"
  label="Email"
  keyboardType="email-address"
  autoCapitalize="none"
  autoComplete="email"
  textContentType="emailAddress"
  returnKeyType="next"
  submitBehavior="submit"
  onSubmitEditing={() => setFocus('password')}
/>

submitBehavior="submit" is the modern replacement for blurOnSubmit={false}. It fires onSubmitEditing without dismissing the keyboard, which is what you want when the return key means "next field". Getting it wrong is why so many React Native forms collapse the keyboard between every field.

How do you move focus to the first invalid field?

handleSubmit takes a second callback that runs when validation fails, and it receives the errors object. Do not iterate Object.keys(errors) and hope the order is right: that order is a by-product of how the resolver assembles the object, and it drifts the moment you reorder the schema, add a .refine() whose path points backwards, or move a field on screen. Declare the visual order once and read from it.

// useSignUpForm.ts, same hook as above
// add to the imports at the top:
//   import { AccessibilityInfo } from 'react-native';
//   import type { FieldErrors } from 'react-hook-form';

const onInvalid = (errors: FieldErrors<SignUpValues>) => {
  const first = FIELD_ORDER.find((name) => errors[name]);
  if (!first) return;

  setFocus(first);
  AccessibilityInfo.announceForAccessibility(
    errors[first]?.message ?? 'Please check the form',
  );
};

// and pass it as the second callback
return { ...form, submit: form.handleSubmit(onValid, onInvalid) };

setFocus works in React Native because field.ref is attached to the TextInput, and react-hook-form simply calls .focus() on whatever it holds. One caveat: do not pass { shouldSelect: true }. That calls .select(), which is a DOM method and does not exist on a React Native input.

Should the submit button be disabled until the form is valid?

No. Disable it while the request is in flight, not while the form is invalid.

A greyed-out button is a dead end: it says something is wrong and refuses to say what. The user taps, nothing happens, and there is no message because the field was never blurred. Leave the button live, let the tap run validation, then show every error at once and focus the first one.

A disabled submit button hides the reason. A live one that fails loudly teaches the user what to fix in one tap.

If you do want formState.isValid, it only means anything when mode is something other than the default 'onSubmit'. On the default it stays false until the first submit, which is how people end up with a button that never enables.

How do you stop the keyboard covering the input?

Use react-native-keyboard-controller. React Native's built-in KeyboardAvoidingView needs different behavior values per platform, does not track the keyboard frame during the animation, and fights with bottom tab bars and safe areas.

It has one hard requirement: KeyboardProvider at the app root. Without it, KeyboardAwareScrollView throws the moment it mounts. Do it once and every screen gets it.

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

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

Then the screen itself, which is now only layout:

// SignUpScreen.tsx
import { Pressable, StyleSheet, Text } from 'react-native';
import { KeyboardAwareScrollView } from 'react-native-keyboard-controller';
import { Field } from './Field';
import { useSignUpForm } from './useSignUpForm';

export function SignUpScreen({ onSignedUp }: { onSignedUp: () => void }) {
  const {
    control,
    setFocus,
    submit,
    formState: { isSubmitting },
  } = useSignUpForm(onSignedUp);

  return (
    <KeyboardAwareScrollView
      bottomOffset={24}
      keyboardShouldPersistTaps="handled"
      contentContainerStyle={{ padding: 20, gap: 18 }}
    >
      <Field
        control={control}
        name="name"
        label="Name"
        autoCapitalize="words"
        textContentType="name"
        returnKeyType="next"
        submitBehavior="submit"
        onSubmitEditing={() => setFocus('email')}
      />

      {/* the email field from above, then password, then confirm */}

      <Field
        control={control}
        name="confirm"
        label="Confirm password"
        secureTextEntry
        textContentType="newPassword"
        returnKeyType="done"
        onSubmitEditing={submit}
      />

      <Pressable onPress={submit} disabled={isSubmitting} style={styles.button}>
        <Text style={styles.buttonLabel}>
          {isSubmitting ? 'Creating account...' : 'Create account'}
        </Text>
      </Pressable>
    </KeyboardAwareScrollView>
  );
}

const styles = StyleSheet.create({
  button: {
    height: 52,
    borderRadius: 26,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: '#111111',
  },
  buttonLabel: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
});

keyboardShouldPersistTaps="handled" is the line that fixes the bug where the first tap on the submit button only dismisses the keyboard and the user has to tap twice.

How do you animate error states without the form jumping?

This is the part every tutorial skips and the part users feel. An error message appearing below an input inserts a line of text into the layout, and every field beneath it snaps down by one line height. Do that on four fields at once after a failed submit and the whole screen lurches. There are two fixes, and the cheap one is usually correct.

Fix one: reserve the space

Give the error row a fixed minimum height so it is always there, and only fade the text in and out. Nothing moves, ever. Pin that height to the error text's own lineHeight so the two cannot drift apart, and it costs you one line of whitespace per field.

// FieldError.tsx
import { StyleSheet, View } from 'react-native';
import Animated, { FadeIn, FadeOut, useReducedMotion } from 'react-native-reanimated';

const ERROR_LINE_HEIGHT = 18;

export function FieldError({ message }: { message?: string }) {
  const reduced = useReducedMotion();

  return (
    <View style={{ minHeight: ERROR_LINE_HEIGHT, justifyContent: 'center' }}>
      {message ? (
        <Animated.Text
          entering={reduced ? undefined : FadeIn.duration(140)}
          exiting={reduced ? undefined : FadeOut.duration(100)}
          accessibilityLiveRegion="polite"
          style={styles.error}
        >
          {message}
        </Animated.Text>
      ) : null}
    </View>
  );
}

const styles = StyleSheet.create({
  error: { fontSize: 13, lineHeight: ERROR_LINE_HEIGHT, color: '#D92D20' },
});

One caveat on that accessibilityLiveRegion="polite": it is Android only. iOS has no live region, so on iPhone nothing is spoken when the text appears. That is exactly why onInvalid calls AccessibilityInfo.announceForAccessibility on submit. Keep both. The live region covers Android's per-field updates, the explicit announcement covers everyone at the moment it matters most.

Fix two: animate the height

If you want the form to stay compact when it is valid, let the row grow and make the fields below it slide rather than snap. In Reanimated v4 that means putting layout on the views that need to move, not just the one that changes size. A sibling only animates its new position if it carries a layout transition itself.

// Field.tsx: swap the outer View for this, so every field wrapper in the
// stack carries a layout transition, not only the one showing an error
import Animated, { LinearTransition } from 'react-native-reanimated';

<Animated.View layout={LinearTransition.duration(180)} style={styles.field}>
  {/* label, input, FieldError */}
</Animated.View>

Reanimated v4 requires the New Architecture. If you are still on the old one, stay on Reanimated 3.x, where the same LinearTransition and FadeIn presets exist.

The shake

A shake on a failed submit is the clearest "this one" signal there is, but it has to fire once per submit attempt, not on every keystroke that leaves the field invalid. formState.submitCount is the trigger: it goes up by one on every submit and never moves while someone types.

// useShakeOnFailedSubmit.ts
import { useEffect, useRef } from 'react';
import * as Haptics from 'expo-haptics';
import {
  useSharedValue,
  useAnimatedStyle,
  useReducedMotion,
  withTiming,
  withSequence,
  withRepeat,
} from 'react-native-reanimated';

export function useShakeOnFailedSubmit(hasError: boolean, submitCount: number) {
  const translateX = useSharedValue(0);
  const reduced = useReducedMotion();
  const lastShaken = useRef(0);

  useEffect(() => {
    if (submitCount === lastShaken.current) return;
    lastShaken.current = submitCount;
    if (!hasError) return;

    Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
    if (reduced) return;

    translateX.value = withSequence(
      withTiming(-8, { duration: 45 }),
      withRepeat(withTiming(8, { duration: 90 }), 3, true),
      withTiming(0, { duration: 45 }),
    );
  }, [submitCount, hasError, reduced, translateX]);

  return useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }] }));
}

It returns a style, so something has to wear it. Put both the hook and the wrapper inside Field, and every invalid field shakes itself:

// Field.tsx, alongside the useController call
// add to the imports at the top:
//   import { useController, useFormState, type Control } from 'react-hook-form';
//   import Animated from 'react-native-reanimated';
//   import { useShakeOnFailedSubmit } from './useShakeOnFailedSubmit';

const { submitCount } = useFormState({ control });
const shake = useShakeOnFailedSubmit(Boolean(fieldState.error), submitCount);

// then the wrapper the label, input and FieldError already live in
// (keep the layout prop here too if you took fix two)
<Animated.View style={[styles.field, shake]}>

useFormState({ control }) is how the field reads submitCount without the parent re-rendering, which is the whole reason you picked react-hook-form. Three details then separate a good shake from a cheap one: the amplitude is small (8 points, not 20), the whole thing is under 400ms, and it is paired with an error haptic so the feedback lands before the eye reaches the text. useReducedMotion skips the movement and keeps the haptic, which is the right call rather than dropping the feedback entirely. The same rules apply to any input with states worth animating, like an OTP field: focus state, error state, and a layout that does not move when either changes.

When is hand-rolled useState validation the right call?

When you have one or two fields, no cross-field rules, and no server-side reuse. A search box, a rename dialog, a single promo-code input. The manual version fits in ten lines, and two dependencies to check one non-empty string is not a trade you need to make.

The line worth watching is cross-field rules. The moment one field's validity depends on another's value, or a rule has to run again after an async check, hand-rolled state starts growing conditionals and you have accidentally written a worse resolver. That is the point to switch.

Formik gets one honest paragraph. It works, it is stable, and it is not worth a migration if you already have twelve forms on it. But it has had long quiet stretches between releases, and its context-based state model is the wrong shape for a screen with animation on it. New form in 2026: react-hook-form. Existing Formik screen nobody is complaining about: leave it alone. TanStack Form, which pairs with Zod or Valibot, is a real third option now, though react-hook-form still has the deeper pool of React Native answers when something breaks at 11pm.

The individual controls this form is made of have their own free references, each with a complete implementation: radio button, number input, OTP input and switch selector.

What is the best form validation library for React Native in 2026?

react-hook-form paired with Zod through @hookform/resolvers/zod. One schema gives you the TypeScript type, the runtime validation and the error messages, and react-hook-form re-renders only the field that changed rather than the whole form. Formik still works but has had long gaps between releases, and its single-context state model re-renders every field on every keystroke, which is noticeable on an animated screen.

Do I need react-hook-form for a two-field form?

No. For one to three fields with no cross-field rules and no server-side schema reuse, plain useState with a validate function is shorter and perfectly correct. Switch to a form library the moment one field's validity depends on another field's value, or when the same rules need to run on your backend, because that is where hand-rolled validation turns into a pile of conditionals.

Does react-hook-form work with React Native TextInput?

Yes, through Controller or the useController hook. You pass field.value to value, field.onChange to onChangeText, field.onBlur to onBlur, and field.ref to the input's ref. Attaching that ref is what makes setFocus(name) work, since react-hook-form calls .focus() on whatever the ref holds and React Native inputs have that method.

How do I move focus to the first invalid field in React Native?

Pass a second callback to handleSubmit. It runs only when validation fails and receives the errors object, so you can find the first invalid field and call setFocus on it. Define your own array of field names in visual order rather than iterating the errors object, because its key order comes from the schema and does not necessarily match the order the fields appear on screen.

Why does my React Native form jump when a validation error appears?

Because the error text is inserted into the layout, which pushes every element below it down instantly. Either reserve the space with a fixed minHeight on the error row so nothing ever moves, or add Reanimated's LinearTransition layout animation to every field wrapper in the stack so they slide into their new positions instead of snapping. The layout prop has to be on the views that move, not only on the one that changed size.

The short version

Zod schema for the rules and the types. react-hook-form with the Zod resolver and mode: 'onTouched'. useController per field with field.ref attached so setFocus works. handleSubmit(onValid, onInvalid) to focus the first failure. react-native-keyboard-controller so the keyboard never covers the input. Then reserve the error row's height, fade the message, and shake once per failed submit.

Validation is a solved problem. The motion around it is where React Native forms still feel unfinished, and it is about forty lines of Reanimated to fix. If you would rather start from a form with all of it already wired, the genie form drop is the one we build ours from.

React NativeExpoReanimatedFormsreact-hook-formZodTypeScript