Building Forms and Validation in React Native
I cover TextInput's controlled input model, keyboard types, react-hook-form's Controller pattern, and Zod schema validation in React Native — plus the accessibility props that replace HTML label elements.

When you type <input type="email"> on the web, the browser does several things at once: it shows an email keyboard on mobile, runs a format check on submit, and enables autofill through the platform API. In React Native, TextInput does none of that automatically. The email keyboard needs keyboardType="email-address". Autofill on iOS needs textContentType="emailAddress". Validation needs a schema or inline rules. Each concern is its own prop.
That explicitness is actually useful once you know the API. The underlying model is the same one you know from React — controlled values, validation on submit, showing errors inline — but React Native makes you opt in to each piece. This post covers the setup that makes it click: TextInput’s event model, keyboard types, react-hook-form with Controller, Zod schema validation, and the accessibility props that replace HTML’s <label> element.
Quick answer: Build controlled inputs with
TextInputandonChangeText, setkeyboardTypeandsecureTextEntryfor input semantics, then useControllerfrom react-hook-form instead ofregister—registerrelies on DOM ref forwarding thatTextInputdoesn’t expose. Wire a Zod schema via@hookform/resolvers/zodfor typed validation that you can share between your React Native and web forms.
Series: Part 4 of 8. Start with Part 1: React Native for React Developers: Getting Started · Previous: State Management in React Native Apps
On this page
- TextInput and the onChangeText model
- Keyboard types: telling the OS what kind of input this is
- react-hook-form with Controller
- Zod schema validation
- Accessibility labels and focus order on mobile
- Where form handling goes wrong
- Validation logic belongs in the schema, not the component
TextInput and the onChangeText model
The controlled input pattern in React Native works identically to the web: keep the value in state, update it on change, read it on submit. What’s different is the event API.
On the web you’d write:
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
In React Native, onChangeText gives you the string directly — no event object to unwrap:
import { useState } from 'react';
import { TextInput, View } from 'react-native';
function EmailField() {
const [email, setEmail] = useState('');
return (
<TextInput
value={email}
onChangeText={setEmail} // receives the new string, not an event
placeholder="Email address"
/>
);
}
There’s no .target.value to reach through. The setter receives the updated string directly, which is why onChangeText={setEmail} works as a one-liner when the state type is a plain string.
The value prop makes TextInput controlled. Omit it and React Native tracks the input’s own internal state — you’d need a ref to read it on submit. For most forms, controlled is the right default: the value stays in your React tree, validation doesn’t require reading the DOM, and you can pre-fill or reset fields from outside the component.
If you’ve read the state management post in this series, the onChangeText example there shows the same pattern for a single field. Forms are just multiple TextInput components wired up the same way — or, better, managed together by a form library.
Keyboard types: telling the OS what kind of input this is
keyboardType is the prop that replaces HTML’s type attribute for keyboard selection. The most useful values across both iOS and Android:
| Value | When to use |
|---|---|
'email-address' |
Email fields |
'numeric' |
Whole number inputs |
'decimal-pad' |
Prices and quantities with decimals |
'phone-pad' |
Phone number fields |
'url' |
URL input fields |
'default' |
General text |
For password fields, secureTextEntry replaces <input type="password">:
<TextInput
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
textContentType="emailAddress"
placeholder="Email"
/>
<TextInput
secureTextEntry
textContentType="password"
placeholder="Password"
/>
textContentType is iOS-specific — it activates the system’s autofill for known semantic types like emailAddress, password, and oneTimeCode. autoCapitalize="none" prevents the OS from capitalising the first letter of an email address. It defaults to 'sentences', which is wrong for most form fields except free-text ones.
Two more props worth setting on every non-trivial form:
returnKeyType changes the return key label. Use 'next' for every field except the last, and 'done' or 'send' on the final one. A form where every return key says “Return” and does nothing useful is noticeably worse to fill out on mobile.
enterKeyHint is a newer cross-platform alternative to returnKeyType that aligns with the HTML inputmode attribute. The React Native documentation notes it takes precedence over returnKeyType when both are set, so pick one.
These are hints to the OS, not constraints. keyboardType="email-address" still accepts notvalid@ if you don’t add validation rules. They improve the experience; a schema is what enforces correctness.
react-hook-form with Controller
The react-hook-form guide for web forms uses register to connect inputs. On React Native, register doesn’t work the same way — it relies on DOM-style ref forwarding that TextInput doesn’t implement. The library’s own documentation for React Native specifies Controller as the correct integration point.
Install the library:
npm install react-hook-form
Controller takes a render prop that receives a field object: onChange, onBlur, value, and ref. Map those to the TextInput props:
import { useForm, Controller, SubmitHandler } from 'react-hook-form';
import { View, TextInput, Text, Pressable, StyleSheet } from 'react-native';
type LoginForm = {
email: string;
password: string;
};
export function LoginScreen() {
const {
control,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
defaultValues: { email: '', password: '' },
mode: 'onBlur',
});
const onSubmit: SubmitHandler<LoginForm> = (data) => {
// send to your API
console.log(data);
};
return (
<View style={styles.form}>
<Controller
control={control}
name="email"
rules={{ required: 'Email is required' }}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[styles.input, errors.email && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
keyboardType="email-address"
autoCapitalize="none"
returnKeyType="next"
/>
)}
/>
{errors.email && (
<Text style={styles.errorText}>{errors.email.message}</Text>
)}
<Controller
control={control}
name="password"
rules={{
required: 'Password is required',
minLength: { value: 8, message: 'At least 8 characters' },
}}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={[styles.input, errors.password && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
secureTextEntry
returnKeyType="done"
/>
)}
/>
{errors.password && (
<Text style={styles.errorText}>{errors.password.message}</Text>
)}
<Pressable style={styles.button} onPress={handleSubmit(onSubmit)}>
<Text style={styles.buttonText}>Sign in</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
form: { padding: 24, gap: 8 },
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
padding: 12,
fontSize: 16,
},
inputError: { borderColor: '#c0392b' },
errorText: { color: '#c0392b', fontSize: 13 },
button: {
backgroundColor: '#1a1a2e',
padding: 16,
borderRadius: 10,
alignItems: 'center',
marginTop: 8,
},
buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },
});
field.onChange maps to onChangeText. field.onBlur maps to onBlur. Everything else — default values, error state, submission handling — works the same as on web.
mode: 'onBlur' validates when a user leaves a field, not on every keystroke. On a mobile keyboard that’s usually the right default: validating while someone types “at least 8 characters” while they’re on character four is more annoying than helpful.
Check this before moving on
- You’re using
Controller, notregister—registerwon’t wire up correctly toTextInput -
field.onChangemaps toonChangeText, notonChange -
errorscomes fromformState: { errors }, destructured fromuseForm
Zod schema validation
Inline rules inside Controller work for short forms. Once you have more than a few fields, or when the same validation needs to run in a web form and a React Native form, pull the schema out into a separate definition.
Install the packages:
npm install zod @hookform/resolvers
Define the schema separately from the component:
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
const loginSchema = z.object({
email: z.string().email('Enter a valid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
type LoginForm = z.infer<typeof loginSchema>;
export function LoginScreen() {
const {
control,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' },
mode: 'onBlur',
});
// Controller components are identical to the previous example
}
z.infer<typeof loginSchema> derives the TypeScript type from the schema — you define the shape once and the type follows automatically. Error messages in z.string().email(...) surface directly in errors.email.message without any extra wiring.
The practical benefit is portability. If you keep the schema in a shared package alongside your TypeScript types, the same loginSchema validates the form in the native app and the web app. The payload shape going to your API is identical in both cases — validate it once at the schema level, and both interfaces inherit the constraint.
Accessibility labels and focus order on mobile
On the web, a <label htmlFor="email-input"> links a visible text label to an input so screen readers announce it when the field receives focus. React Native has no <label> equivalent. The accessibility name for an input comes from accessibilityLabel on the TextInput itself:
<TextInput
accessibilityLabel="Email address"
accessibilityHint="Enter the email you registered with"
keyboardType="email-address"
autoCapitalize="none"
value={email}
onChangeText={setEmail}
/>
accessibilityLabel is what VoiceOver (iOS) and TalkBack (Android) read when the field receives focus. accessibilityHint provides additional context — use it when the label alone doesn’t tell the user what format or content to enter.
For inline error messages, wrap them in a view with accessibilityLiveRegion="polite" so assistive technology announces them when they appear without the user having to navigate there:
<View accessibilityLiveRegion="polite">
{errors.email && (
<Text accessibilityRole="alert">{errors.email.message}</Text>
)}
</View>
One thing most first-attempt forms miss: the software keyboard covers form fields on smaller screens. Wrap the form in KeyboardAvoidingView to shift the content out of the way:
import { KeyboardAvoidingView, Platform } from 'react-native';
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ flex: 1 }}
>
{/* form content */}
</KeyboardAvoidingView>
The behavior prop works differently on each platform. 'padding' adds bottom padding on iOS to push the content up. 'height' shrinks the view on Android. If the keyboard still overlaps a field on Android after using 'height', check whether windowSoftInputMode in AndroidManifest.xml is set to adjustPan — changing it to adjustResize is usually the fix.
The web forms and validation UX post covers the web counterpart to these patterns. The concepts — label association, live regions, focus management — map directly across; the API names are different.
Where form handling goes wrong
Validating on every keystroke is the most common UX mistake. mode: 'onChange' in useForm shows “at least 8 characters” while a user is typing the fourth character of their password. mode: 'onBlur' validates after the user finishes and moves to the next field, which matches what most people expect.
returnKeyType without onSubmitEditing changes the label but moves no focus. A returnKeyType="next" field needs an onSubmitEditing handler that calls ref.current?.focus() on the next TextInput. Keep a useRef<TextInput>(null) for each field, pass it to the ref prop, and chain the focus calls through your form.
KeyboardAvoidingView inside a ScrollView causes layout fights. The correct nesting is KeyboardAvoidingView wrapping ScrollView, not the reverse. Also set keyboardShouldPersistTaps="handled" on the ScrollView — without it, tapping a field inside a scroll container dismisses the keyboard before the field receives focus.
Skipping accessibilityLabel makes the form unusable with a screen reader. Without it, VoiceOver reads “text field” with no context. Adding accessibilityLabel to every input takes two minutes on a full login form and fixes the experience for a real portion of users.
Validation logic belongs in the schema, not the component
The clearest mental model for React Native forms: validation lives in the schema; UX lives in the component. The schema (Zod or Yup) defines what’s valid. The component defines what the experience feels like — keyboardType, accessibilityLabel, error styling, return key behaviour, KeyboardAvoidingView.
Those two layers separate cleanly. A loginSchema can live in a shared TypeScript package and serve both the web form and the native form. The Controller components are the adapters that connect the schema’s output to TextInput’s input.
The Controller pattern is more verbose than register, but it’s explicit — you see exactly which props map to which field state. Once you’ve written one form, it becomes a template you replicate across the rest of the app.
Start with mode: 'onBlur', add accessibilityLabel to every field, and wrap the form in KeyboardAvoidingView. Those three things cover the most important mobile-specific differences before you’ve written a single validation rule.

The schema defines the rules; the Controller pattern adapts them to TextInput’s event model.