Mehdi Davoodi11 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.
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