Pick the stack once
| Approach | Works in React Native? | Schema reuse | Re-renders | When to use it |
|---|---|---|---|---|
| react-hook-form + Zod | Yes — via Controller | Shared with backend | Per-field, minimal | The default for new apps |
| react-hook-form + Yup | Yes — via Controller | Shared, weaker TS inference | Per-field, minimal | Fine if Yup is already in the codebase |
| Formik + Yup | Yes | Shared | Whole form on each keystroke | Existing projects only |
| useState by hand | Yes | None | Whatever you write | One or two fields, no rules |
The re-render column is the one that shows up on a mid-range Android phone. Formik re-renders the entire form on every keystroke; react-hook-form keeps field state in refs and only re-renders what changed. On a three-field login you will never notice. On a twelve-field onboarding flow you will.
Step 1 — Install
npx expo install react-hook-form zod @hookform/resolversAll three are pure JavaScript — no native modules, no rebuild, and they work in Expo Go. That is unusual enough in React Native to be worth saying out loud.
Step 2 — One schema, two consumers
Write the rules once, in a file your app and your API can both import. This is the whole argument for Zod over hand-rolled checks:
// lib/schemas/signup.ts
import { z } from 'zod';
export const signupSchema = z
.object({
email: z.string().min(1, 'Email is required').email('That email looks off'),
password: z
.string()
.min(8, 'At least 8 characters')
.regex(/[0-9]/, 'Include at least one number'),
confirm: z.string(),
})
.refine((v) => v.password === v.confirm, {
message: "Passwords don't match",
path: ['confirm'], // attach the error to the confirm field
});
export type SignupValues = z.infer<typeof signupSchema>;Two details that pay off later: every message is written for a human (“That email looks off”, not “Invalid email”), and path in the refine puts the mismatch error on the confirm field instead of floating at the top of the form where nobody connects it to an input.
Step 3 — The complete form
This is a full working screen. The important part is Controller: it takes the place of register() and hands you onChange, onBlur, and value to pass into a plain TextInput.
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
KeyboardAvoidingView, Platform, Pressable, ScrollView,
StyleSheet, Text, TextInput, View,
} from 'react-native';
import { signupSchema, type SignupValues } from '../lib/schemas/signup';
export default function SignupScreen() {
const {
control,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<SignupValues>({
resolver: zodResolver(signupSchema),
mode: 'onTouched', // validate after first blur, then live
defaultValues: { email: '', password: '', confirm: '' },
});
const onSubmit = async (values: SignupValues) => {
const res = await fetch('/api/signup', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
});
if (res.status === 409) {
// server rejected it — put the error back on the field that caused it
setError('email', { message: 'That email is already registered' });
return;
}
// navigate on success…
};
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView
contentContainerStyle={styles.body}
keyboardShouldPersistTaps="handled"
>
<Field label="Email" error={errors.email?.message}>
<Controller
control={control}
name="email"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[styles.input, errors.email && styles.inputError]}
value={value}
onChangeText={onChange}
onBlur={onBlur}
placeholder="you@example.com"
placeholderTextColor="#666"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
/>
)}
/>
</Field>
<Field label="Password" error={errors.password?.message}>
<Controller
control={control}
name="password"
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[styles.input, errors.password && styles.inputError]}
value={value}
onChangeText={onChange}
onBlur={onBlur}
secureTextEntry
autoComplete="new-password"
/>
)}
/>
</Field>
<Pressable
style={[styles.button, isSubmitting && { opacity: 0.6 }]}
disabled={isSubmitting}
onPress={handleSubmit(onSubmit)}
>
<Text style={styles.buttonText}>
{isSubmitting ? 'Creating account…' : 'Create account'}
</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
);
}
function Field({ label, error, children }) {
return (
<View style={{ marginBottom: 18 }}>
<Text style={styles.label}>{label}</Text>
{children}
{!!error && <Text style={styles.error}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
body: { padding: 24, paddingTop: 60 },
label: { color: '#fff', marginBottom: 6, fontWeight: '600' },
input: {
borderWidth: 1, borderColor: '#333', borderRadius: 10,
padding: 14, color: '#fff', backgroundColor: '#141414',
},
inputError: { borderColor: '#f87171' },
error: { color: '#f87171', marginTop: 6, fontSize: 13 },
button: { backgroundColor: '#fb923c', borderRadius: 10, padding: 16, alignItems: 'center' },
buttonText: { color: '#1c1c1c', fontWeight: '700' },
});Note handleSubmit(onSubmit) — it runs validation first and only calls your function with typed, valid values. You never check the fields yourself inside onSubmit.
The four things that actually break
- The submit button eats the first tap. Inside a
ScrollView, the first tap while the keyboard is open just dismisses the keyboard. SetkeyboardShouldPersistTaps="handled"and the tap lands on the button. This is the single most common “my form doesn’t submit” bug. - Errors render behind the keyboard. An error banner at the top of a scrolled form is invisible on a phone. Put each message directly under its input, and add
KeyboardAvoidingViewso the focused field stays in view. - Validating on every keystroke.
mode: 'onChange'marksj@as an invalid email while the user is still typing their address.'onTouched'waits for the first blur, then updates live while they fix it — strict where it helps, quiet where it doesn’t. - Server errors with nowhere to go.“Email already registered” is only knowable on the server.
setErrorputs that message back on the email field so it renders in the same place as every other error, instead of in an alert the user has to dismiss and then re-find the field.
Client validation is not security
Everything above is a user-experience feature. Anyone can post directly to your endpoint and skip the app entirely. Because the schema is a plain module, the server check is three lines:
import { signupSchema } from '../lib/schemas/signup';
const parsed = signupSchema.safeParse(await req.json());
if (!parsed.success) {
return Response.json({ errors: parsed.error.flatten() }, { status: 400 });
}
// parsed.data is now typed and trustedIf you are storing this data in Postgres, the same discipline applies one layer down — see adding a real database for row-level security, which is the check that survives even a compromised API key.
The shortcut
Forms are the most repetitive code in a mobile app and the least interesting to write for the fifth time. Describe the fields and the rules in a sentence — “signup with email, a password of at least 8 characters with a number, and a matching confirm field” — and ShipNative generates the schema, the Controller wiring, the error states, and the keyboard handling as real React Native you can preview on your phone and export in full. Then you spend your time on the part of the product only you can write.