Why Forms in React Native Are Different
If you've built forms on the web with React Hook Form, you know how effortless it can be — register an input, attach validation rules, and you're done. React Native changes the equation entirely. There's no DOM, no native ref forwarding on TextInput the way you'd expect, and no HTML form submission to lean on.
Every input has to be explicitly wired through the Controller component. Every interaction needs to feel snappy on a 60fps touch interface. It's a different world.
This guide walks you through building production-ready, type-safe forms in React Native using React Hook Form 7.71 and Zod 4 — the validation stack that's become the go-to standard in 2026. We'll cover the Controller pattern, reusable input components, multi-step wizards, async validation, and the performance patterns that keep your forms feeling instant.
Project Setup
Prerequisites
You'll need an existing React Native or Expo project running React 18+ and TypeScript. This tutorial uses Expo SDK 55, but the patterns work identically with bare React Native.
Install Dependencies
npx expo install react-hook-form @hookform/resolvers zod
That's three packages total:
- react-hook-form (v7.71) — form state management with minimal re-renders
- @hookform/resolvers — bridges React Hook Form with external schema validators
- zod (v4.3) — TypeScript-first schema validation that's 3x faster and 57% smaller than Zod 3
Defining Your First Zod Schema
Start with the validation schema. In Zod 4, you define your data shape once and infer TypeScript types directly from it — no duplicate type definitions needed. Honestly, this is one of those things that feels almost too good once you get used to it.
import { z } from 'zod';
const loginSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Include at least one uppercase letter')
.regex(/[0-9]/, 'Include at least one number'),
});
type LoginFormData = z.infer<typeof loginSchema>;
The LoginFormData type is automatically derived from the schema. Change the schema, and TypeScript catches every mismatch at compile time. No more keeping two separate type definitions in sync.
Basic Login Form with Controller
In React Native, you can't use register directly because TextInput doesn't expose an HTML-compatible ref. Instead, you wrap every input with the Controller component.
Here's what a complete login form looks like:
import React from 'react';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const loginSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Include at least one uppercase letter')
.regex(/[0-9]/, 'Include at least one number'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export default function LoginForm() {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' },
});
const onSubmit = async (data: LoginFormData) => {
// Call your authentication API here
console.log('Form submitted:', data);
};
return (
<View style={styles.container}>
<Text style={styles.label}>Email</Text>
<Controller
control={control}
name="email"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[styles.input, errors.email && styles.inputError]}
onChangeText={onChange}
onBlur={onBlur}
value={value}
placeholder="[email protected]"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
)}
/>
{errors.email && (
<Text style={styles.error}>{errors.email.message}</Text>
)}
<Text style={styles.label}>Password</Text>
<Controller
control={control}
name="password"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[styles.input, errors.password && styles.inputError]}
onChangeText={onChange}
onBlur={onBlur}
value={value}
placeholder="Enter your password"
secureTextEntry
autoComplete="password"
/>
)}
/>
{errors.password && (
<Text style={styles.error}>{errors.password.message}</Text>
)}
<Pressable
style={[styles.button, isSubmitting && styles.buttonDisabled]}
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting}
>
<Text style={styles.buttonText}>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 24, gap: 4 },
label: { fontSize: 14, fontWeight: '600', marginTop: 12 },
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 12,
fontSize: 16,
marginTop: 4,
},
inputError: { borderColor: '#ef4444' },
error: { color: '#ef4444', fontSize: 12, marginTop: 2 },
button: {
backgroundColor: '#2563eb',
borderRadius: 8,
padding: 14,
alignItems: 'center',
marginTop: 20,
},
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
A few things worth calling out here:
zodResolver(loginSchema)connects Zod validation to React Hook FormdefaultValuesmust be provided — React Hook Form uses them to initialize controlled inputsonChangeTextmaps toonChangedirectly — React Hook Form expects a value, andTextInput'sonChangeTextconveniently passes just the stringisSubmittingdisables the button during async operations to prevent double submissions (something that bites you more often than you'd think on mobile)
Creating Reusable Form Input Components
Repeating the Controller boilerplate for every field doesn't scale. At all. Once you've got more than three or four fields, your component turns into a wall of repetitive JSX.
The better approach is to encapsulate the Controller logic inside a reusable component:
import React from 'react';
import { View, Text, TextInput, TextInputProps, StyleSheet } from 'react-native';
import { useController, useFormContext, FieldValues, Path } from 'react-hook-form';
type FormInputProps<T extends FieldValues> = {
name: Path<T>;
label: string;
} & Omit<TextInputProps, 'value' | 'onChangeText' | 'onBlur'>;
export function FormInput<T extends FieldValues>({
name,
label,
...inputProps
}: FormInputProps<T>) {
const { control } = useFormContext<T>();
const {
field: { onChange, onBlur, value },
fieldState: { error },
} = useController({ name, control });
return (
<View style={styles.wrapper}>
<Text style={styles.label}>{label}</Text>
<TextInput
style={[styles.input, error && styles.inputError]}
onChangeText={onChange}
onBlur={onBlur}
value={value}
{...inputProps}
/>
{error && <Text style={styles.error}>{error.message}</Text>}
</View>
);
}
const styles = StyleSheet.create({
wrapper: { marginBottom: 12 },
label: { fontSize: 14, fontWeight: '600', marginBottom: 4 },
input: {
borderWidth: 1,
borderColor: '#d1d5db',
borderRadius: 8,
padding: 12,
fontSize: 16,
},
inputError: { borderColor: '#ef4444' },
error: { color: '#ef4444', fontSize: 12, marginTop: 2 },
});
This uses useController instead of Controller — they're functionally identical, but the hook version is cleaner inside custom components. The generic Path<T> type ensures that the name prop autocompletes to valid field names from your schema, which is a really nice DX win.
Using the Reusable Component
Wrap your form with FormProvider so child components can access form state through useFormContext:
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { FormInput } from './FormInput';
export default function LoginForm() {
const methods = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' },
});
return (
<FormProvider {...methods}>
<View style={{ padding: 24 }}>
<FormInput<LoginFormData>
name="email"
label="Email"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
/>
<FormInput<LoginFormData>
name="password"
label="Password"
secureTextEntry
autoComplete="password"
/>
<Pressable onPress={methods.handleSubmit(onSubmit)}>
<Text>Sign In</Text>
</Pressable>
</View>
</FormProvider>
);
}
Now adding a new field is a single line. No more Controller boilerplate cluttering up your forms.
Advanced Validation Patterns
Cross-Field Validation
Use Zod's .refine() method to validate fields that depend on each other. The classic example is password confirmation:
const signupSchema = z
.object({
email: z.string().min(1, 'Required').email('Invalid email'),
password: z.string().min(8, 'At least 8 characters'),
confirmPassword: z.string().min(1, 'Please confirm your password'),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type SignupFormData = z.infer<typeof signupSchema>;
The path option targets the error to the confirmPassword field so it shows up beneath the correct input. Without it, the error would be a root-level form error, which is harder to display nicely.
Conditional Validation
Sometimes fields only apply under certain conditions — say, a company name that's required only when the user picks a business account:
const profileSchema = z
.object({
accountType: z.enum(['personal', 'business']),
name: z.string().min(1, 'Name is required'),
companyName: z.string().optional(),
})
.refine(
(data) =>
data.accountType !== 'business' ||
(data.companyName && data.companyName.length > 0),
{
message: 'Company name is required for business accounts',
path: ['companyName'],
}
);
Async Validation — Checking Username Availability
This is one of those features that users really notice. Use Zod's .refine() with an async callback, and combine it with React Hook Form's mode: 'onBlur' so the API call fires only when the user leaves the field — not on every single keystroke:
const checkUsername = async (username: string): Promise<boolean> => {
const response = await fetch(
`https://api.example.com/check-username?u=${encodeURIComponent(username)}`
);
const data = await response.json();
return data.available;
};
const usernameSchema = z.object({
username: z
.string()
.min(3, 'At least 3 characters')
.regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores')
.refine(checkUsername, {
message: 'This username is already taken',
}),
});
// In your form setup:
const methods = useForm<z.infer<typeof usernameSchema>>({
resolver: zodResolver(usernameSchema),
mode: 'onBlur', // validate when the field loses focus
});
Building a Multi-Step Form
Complex workflows like onboarding or checkout flows really benefit from splitting the form into discrete steps. The key insight here — and it took me a while to land on this — is using a single useForm instance shared across all steps via FormProvider.
Step Schemas
Define a separate Zod schema for each step, then merge them into one master schema:
const step1Schema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().email('Invalid email'),
});
const step2Schema = z.object({
street: z.string().min(1, 'Street address is required'),
city: z.string().min(1, 'City is required'),
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/, 'Invalid ZIP code'),
});
const step3Schema = z.object({
cardNumber: z.string().min(13, 'Invalid card number'),
expiry: z.string().regex(/^\d{2}\/\d{2}$/, 'Use MM/YY format'),
cvv: z.string().length(3, 'CVV must be 3 digits'),
});
const checkoutSchema = step1Schema.merge(step2Schema).merge(step3Schema);
type CheckoutFormData = z.infer<typeof checkoutSchema>;
// Map step index to the fields that belong to it
const stepFields: Record<number, (keyof CheckoutFormData)[]> = {
0: ['firstName', 'lastName', 'email'],
1: ['street', 'city', 'zipCode'],
2: ['cardNumber', 'expiry', 'cvv'],
};
Multi-Step Container
import React, { useState } from 'react';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
export default function CheckoutWizard() {
const [step, setStep] = useState(0);
const methods = useForm<CheckoutFormData>({
resolver: zodResolver(checkoutSchema),
defaultValues: {
firstName: '', lastName: '', email: '',
street: '', city: '', zipCode: '',
cardNumber: '', expiry: '', cvv: '',
},
shouldUnregister: false, // retain values across steps
mode: 'onTouched',
});
const nextStep = async () => {
const fields = stepFields[step];
const valid = await methods.trigger(fields);
if (valid) setStep((s) => s + 1);
};
const prevStep = () => setStep((s) => s - 1);
const onSubmit = async (data: CheckoutFormData) => {
console.log('Order placed:', data);
};
const steps = [<PersonalInfo />, <ShippingAddress />, <PaymentDetails />];
return (
<FormProvider {...methods}>
<View style={styles.container}>
<Text style={styles.progress}>
Step {step + 1} of {steps.length}
</Text>
{steps[step]}
<View style={styles.nav}>
{step > 0 && (
<Pressable onPress={prevStep} style={styles.backBtn}>
<Text>Back</Text>
</Pressable>
)}
{step < steps.length - 1 ? (
<Pressable onPress={nextStep} style={styles.nextBtn}>
<Text style={styles.nextText}>Next</Text>
</Pressable>
) : (
<Pressable
onPress={methods.handleSubmit(onSubmit)}
style={styles.nextBtn}
>
<Text style={styles.nextText}>Place Order</Text>
</Pressable>
)}
</View>
</View>
</FormProvider>
);
}
The critical pieces here:
shouldUnregister: falsekeeps field values in memory when a step component unmounts — without this, going back a step would wipe the user's datamethods.trigger(fields)validates only the current step's fields before advancing- Each step component uses the reusable
FormInputfrom earlier, pulling form state throughuseFormContext
Handling Picker and Custom Inputs
Not every input is a TextInput. For dropdowns, date pickers, and switches, you'll want specialized wrappers. Here's one for a boolean toggle:
import { useController, useFormContext, FieldValues, Path } from 'react-hook-form';
import { View, Text, Switch, StyleSheet } from 'react-native';
type FormSwitchProps<T extends FieldValues> = {
name: Path<T>;
label: string;
};
export function FormSwitch<T extends FieldValues>({
name,
label,
}: FormSwitchProps<T>) {
const { control } = useFormContext<T>();
const {
field: { onChange, value },
fieldState: { error },
} = useController({ name, control });
return (
<View style={styles.row}>
<Text style={styles.label}>{label}</Text>
<Switch value={value} onValueChange={onChange} />
{error && <Text style={styles.error}>{error.message}</Text>}
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 },
label: { fontSize: 14, fontWeight: '600' },
error: { color: '#ef4444', fontSize: 12 },
});
The same pattern works for date pickers, segmented controls, and radio groups. The key thing to remember is that onChange accepts any value — not just strings — so you can pass booleans, dates, or objects directly.
Performance Optimization
React Hook Form is already optimized for minimal re-renders, but there are a few extra things you can do in React Native to keep things smooth.
1. Use mode: 'onTouched' or 'onBlur'
Avoid mode: 'onChange' for large forms. Validating on every keystroke triggers a re-render of every field that currently has an error, and on lower-end devices that adds up fast. onTouched validates when a field is first blurred and then on subsequent changes — a solid balance of responsiveness and performance.
2. Isolate Re-Renders with useFormState
If you need to show a real-time character count or validation status for a single field without re-rendering the entire form, useFormState is your friend:
import { useFormState } from 'react-hook-form';
function PasswordStrength() {
const { errors, dirtyFields } = useFormState({ name: 'password' });
// Only this component re-renders when the password field changes
return dirtyFields.password && !errors.password ? (
<Text style={{ color: '#22c55e' }}>Strong password</Text>
) : null;
}
3. Debounce Expensive Validations
For async validations like username checks, add a debounce so you're not hammering your API on every keystroke:
import { useCallback, useRef } from 'react';
function useDebouncedValidation(delay = 500) {
const timeoutRef = useRef<NodeJS.Timeout>();
return useCallback(
(callback: () => void) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(callback, delay);
},
[delay]
);
}
4. Avoid Inline Functions in render
When using Controller, the render prop creates a new function on every render cycle. For forms with many fields, this can add up. Extract the render function or (better yet) use the useController hook pattern shown earlier — it sidesteps this overhead entirely.
Error Handling UX Patterns
Good validation logic means nothing if users can't understand or find the errors. These patterns make a real difference in how polished your forms feel.
Scroll to First Error
In long forms, automatically scrolling to the first error field after a failed submission is one of those small touches that dramatically improves the experience:
import { useRef } from 'react';
import { ScrollView, TextInput } from 'react-native';
export default function LongForm() {
const scrollRef = useRef<ScrollView>(null);
const fieldRefs = useRef<Record<string, number>>({});
const onError = (errors: any) => {
const firstErrorField = Object.keys(errors)[0];
const y = fieldRefs.current[firstErrorField];
if (y !== undefined) {
scrollRef.current?.scrollTo({ y: y - 20, animated: true });
}
};
return (
<ScrollView ref={scrollRef}>
<View
onLayout={(e) => {
fieldRefs.current['email'] = e.nativeEvent.layout.y;
}}
>
{/* email input */}
</View>
<Pressable onPress={handleSubmit(onSubmit, onError)}>
<Text>Submit</Text>
</Pressable>
</ScrollView>
);
}
Inline Error Animation
Pair error messages with a subtle shake or fade-in using React Native Reanimated. Even a simple opacity transition from 0 to 1 when an error appears makes the form feel more responsive and intentional.
Accessibility
Don't forget to add accessibilityLabel to your inputs and link error messages using accessibilityHint. It's easy to overlook, but it matters:
<TextInput
accessibilityLabel="Email address"
accessibilityHint={error ? error.message : 'Enter your email address'}
accessible
// ... other props
/>
Complete Registration Form Example
So, let's bring it all together. Here's a full registration form that ties together reusable components, cross-field validation, and the FormProvider pattern:
import React from 'react';
import { View, Text, Pressable, ScrollView, StyleSheet } from 'react-native';
import { FormProvider, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { FormInput } from './FormInput';
import { FormSwitch } from './FormSwitch';
const registrationSchema = z
.object({
fullName: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Enter a valid email'),
username: z
.string()
.min(3, 'At least 3 characters')
.max(20, 'Maximum 20 characters')
.regex(/^[a-z0-9_]+$/, 'Only lowercase letters, numbers, and underscores'),
password: z
.string()
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'One uppercase letter required')
.regex(/[0-9]/, 'One number required'),
confirmPassword: z.string(),
acceptTerms: z.literal(true, {
errorMap: () => ({ message: 'You must accept the terms' }),
}),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'],
});
type RegistrationData = z.infer<typeof registrationSchema>;
export default function RegistrationForm() {
const methods = useForm<RegistrationData>({
resolver: zodResolver(registrationSchema),
defaultValues: {
fullName: '',
email: '',
username: '',
password: '',
confirmPassword: '',
acceptTerms: false as unknown as true,
},
mode: 'onTouched',
});
const onSubmit = async (data: RegistrationData) => {
const { confirmPassword, ...payload } = data;
// Send payload to your API
console.log('Registration data:', payload);
};
return (
<FormProvider {...methods}>
<ScrollView contentContainerStyle={styles.container}>
<Text style={styles.title}>Create Account</Text>
<FormInput<RegistrationData> name="fullName" label="Full Name" autoComplete="name" />
<FormInput<RegistrationData> name="email" label="Email" keyboardType="email-address" autoCapitalize="none" autoComplete="email" />
<FormInput<RegistrationData> name="username" label="Username" autoCapitalize="none" />
<FormInput<RegistrationData> name="password" label="Password" secureTextEntry />
<FormInput<RegistrationData> name="confirmPassword" label="Confirm Password" secureTextEntry />
<FormSwitch<RegistrationData> name="acceptTerms" label="I accept the terms and conditions" />
<Pressable
onPress={methods.handleSubmit(onSubmit)}
style={[styles.button, methods.formState.isSubmitting && styles.buttonDisabled]}
disabled={methods.formState.isSubmitting}
>
<Text style={styles.buttonText}>
{methods.formState.isSubmitting ? 'Creating account...' : 'Sign Up'}
</Text>
</Pressable>
</ScrollView>
</FormProvider>
);
}
const styles = StyleSheet.create({
container: { padding: 24 },
title: { fontSize: 28, fontWeight: '700', marginBottom: 24 },
button: {
backgroundColor: '#2563eb',
borderRadius: 8,
padding: 14,
alignItems: 'center',
marginTop: 20,
},
buttonDisabled: { opacity: 0.6 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
Frequently Asked Questions
Why use Zod instead of Yup for React Native form validation?
Zod 4 was built for TypeScript from the start. You define your schema once and infer types with z.infer — no separate type definitions to keep in sync. It's also 57% smaller and up to 3x faster than Zod 3, and the API for features like .refine() and .transform() feels more intuitive than Yup's equivalents. Yup still works fine, but Zod has become the community default for TypeScript projects in 2026.
Can I use react-hook-form without Controller in React Native?
Not directly. React Hook Form's register method relies on DOM refs, which React Native's TextInput doesn't support in the same way. You need to use the Controller component or the useController hook. The reusable component pattern shown earlier in this guide makes this painless — you write the Controller logic once and reuse it everywhere.
How do I validate forms per step in a multi-step wizard?
Use a single useForm instance at the root with shouldUnregister: false to keep values across steps. Define separate Zod schemas for each step, then call methods.trigger(['field1', 'field2']) with only the current step's field names before advancing. This validates just the visible fields without triggering errors on steps the user hasn't reached yet.
Does react-hook-form work with Expo and the New Architecture?
Yes, fully. React Hook Form v7.71 is compatible with Expo SDK 55, React Native 0.79+, and the New Architecture (Fabric and TurboModules). Since the library is pure JavaScript with no native dependencies, it works identically across all React Native configurations.
How do I handle async validation like checking if a username is taken?
Use Zod's .refine() with an async callback that returns a boolean. Set mode: 'onBlur' in your useForm config so validation only triggers when the user tabs away from the field. For an even smoother experience, add a debounce wrapper to prevent excessive API calls while the user is still typing.