Module 7: Data Management and Networking
Forms and Validation
Building robust, user-friendly forms in React Native
🎯 Learning Objectives
- Understand controlled vs uncontrolled form patterns in React Native
- Implement forms efficiently with React Hook Form
- Define validation schemas with Zod
- Display validation errors clearly to users
- Handle keyboard interactions and input focus properly
- Build reusable form components
- Implement common patterns: login, registration, and multi-step forms
Form Challenges in React Native
Forms in React Native come with unique challenges that don't exist on the web. Understanding these challenges will help you build better user experiences.
📱 Mobile Form Challenges
- Keyboard covers input: The soft keyboard can hide the field being edited
- No HTML form element: No native form submission, validation, or autocomplete
- Focus management: Moving between inputs requires manual handling
- Different keyboards: Need to specify keyboard type for each input
- Dismissing keyboard: Users need a way to hide the keyboard
- Performance: Many inputs can cause re-render performance issues
Controlled Forms: The Basic Approach
Before we introduce libraries, let's understand the controlled form pattern—where React state drives the form values.
import { useState } from 'react';
import {
View,
Text,
TextInput,
Pressable,
StyleSheet,
Alert
} from 'react-native';
interface FormData {
email: string;
password: string;
}
interface FormErrors {
email?: string;
password?: string;
}
export default function BasicLoginForm() {
const [formData, setFormData] = useState<FormData>({
email: '',
password: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
// Update a single field
const handleChange = (field: keyof FormData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when user starts typing
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: undefined }));
}
};
// Validate all fields
const validate = (): boolean => {
const newErrors: FormErrors = {};
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Invalid email format';
}
if (!formData.password) {
newErrors.password = 'Password is required';
} else if (formData.password.length < 8) {
newErrors.password = 'Password must be at least 8 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle submission
const handleSubmit = async () => {
if (!validate()) return;
setIsSubmitting(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
Alert.alert('Success', 'Logged in successfully!');
} catch (error) {
Alert.alert('Error', 'Login failed');
} finally {
setIsSubmitting(false);
}
};
return (
<View style={styles.container}>
<View style={styles.field}>
<Text style={styles.label}>Email</Text>
<TextInput
style={[styles.input, errors.email && styles.inputError]}
value={formData.email}
onChangeText={(value) => handleChange('email', value)}
placeholder="your@email.com"
keyboardType="email-address"
autoCapitalize="none"
autoCorrect={false}
/>
{errors.email && (
<Text style={styles.errorText}>{errors.email}</Text>
)}
</View>
<View style={styles.field}>
<Text style={styles.label}>Password</Text>
<TextInput
style={[styles.input, errors.password && styles.inputError]}
value={formData.password}
onChangeText={(value) => handleChange('password', value)}
placeholder="••••••••"
secureTextEntry
/>
{errors.password && (
<Text style={styles.errorText}>{errors.password}</Text>
)}
</View>
<Pressable
style={[styles.button, isSubmitting && styles.buttonDisabled]}
onPress={handleSubmit}
disabled={isSubmitting}
>
<Text style={styles.buttonText}>
{isSubmitting ? 'Logging in...' : 'Log In'}
</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: {
padding: 20,
},
field: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '600',
marginBottom: 6,
color: '#333',
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
fontSize: 16,
backgroundColor: '#fff',
},
inputError: {
borderColor: '#f44336',
},
errorText: {
color: '#f44336',
fontSize: 12,
marginTop: 4,
},
button: {
backgroundColor: '#667eea',
padding: 16,
borderRadius: 8,
alignItems: 'center',
marginTop: 8,
},
buttonDisabled: {
backgroundColor: '#bbb',
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
});
⚠️ Problems with This Approach
- Boilerplate: Every form needs the same state, validation, and error handling code
- Re-renders: Every keystroke re-renders the entire form
- Validation logic: Grows complex and hard to maintain
- Type safety: Manual typing of errors and validation
This is why we use React Hook Form and Zod.
React Hook Form: The Better Way
React Hook Form minimizes re-renders by using uncontrolled inputs with refs under the hood, while still giving you the familiar controlled component API. It's the most popular form library for React and works great in React Native.
Installation
npx expo install react-hook-form
Basic Usage
import { useForm, Controller } from 'react-hook-form';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
interface LoginFormData {
email: string;
password: string;
}
export default function LoginForm() {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
defaultValues: {
email: '',
password: '',
},
});
const onSubmit = async (data: LoginFormData) => {
console.log('Form data:', data);
// Handle login...
};
return (
<View style={styles.container}>
<Controller
control={control}
name="email"
rules={{
required: 'Email is required',
pattern: {
value: /\S+@\S+\.\S+/,
message: 'Invalid email format',
},
}}
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<Text style={styles.label}>Email</Text>
<TextInput
style={[styles.input, errors.email && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholder="your@email.com"
keyboardType="email-address"
autoCapitalize="none"
/>
{errors.email && (
<Text style={styles.errorText}>{errors.email.message}</Text>
)}
</View>
)}
/>
<Controller
control={control}
name="password"
rules={{
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
}}
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<Text style={styles.label}>Password</Text>
<TextInput
style={[styles.input, errors.password && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholder="••••••••"
secureTextEntry
/>
{errors.password && (
<Text style={styles.errorText}>{errors.password.message}</Text>
)}
</View>
)}
/>
<Pressable
style={[styles.button, isSubmitting && styles.buttonDisabled]}
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting}
>
<Text style={styles.buttonText}>
{isSubmitting ? 'Logging in...' : 'Log In'}
</Text>
</Pressable>
</View>
);
}
Understanding Controller
Unlike web React where you can use register directly on inputs, React Native requires the Controller component because TextInput doesn't support refs the same way HTML inputs do.
flowchart LR
A[Controller] --> B[Manages field state]
A --> C[Tracks validation]
A --> D[Handles onChange/onBlur]
B --> E[render prop]
C --> E
D --> E
E --> F[Your TextInput]
style A fill:#667eea,color:#fff
style F fill:#e3f2fd,stroke:#2196F3
Useful Form State
const {
control,
handleSubmit,
watch, // Watch field values
setValue, // Programmatically set values
reset, // Reset form to defaults
getValues, // Get current values
trigger, // Trigger validation manually
formState: {
errors, // Validation errors
isSubmitting, // Form is being submitted
isValid, // All fields are valid
isDirty, // Form has been modified
dirtyFields, // Which fields were modified
touchedFields, // Which fields were touched
},
} = useForm<FormData>();
✅ React Hook Form Benefits
- Minimal re-renders: Only the field being edited re-renders
- Built-in validation: Required, min, max, pattern, custom
- Great DX: TypeScript support, DevTools extension
- Tiny: ~9KB gzipped with no dependencies
- Easy integration: Works with any UI library
Zod: Type-Safe Validation
While React Hook Form's built-in validation is fine for simple forms, complex validation benefits from a dedicated schema library. Zod provides type-safe schemas that work beautifully with TypeScript.
Installation
npx expo install zod @hookform/resolvers
Creating a Schema
import { z } from 'zod';
// Define your schema
const loginSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Invalid email format'),
password: z
.string()
.min(1, 'Password is required')
.min(8, 'Password must be at least 8 characters'),
});
// TypeScript type is automatically inferred!
type LoginFormData = z.infer<typeof loginSchema>;
// Equivalent to:
// type LoginFormData = {
// email: string;
// password: string;
// }
Integration with React Hook Form
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('Invalid email'),
password: z.string().min(1, 'Password is required').min(8, 'Min 8 characters'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export default function LoginForm() {
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema), // Use Zod for validation!
defaultValues: {
email: '',
password: '',
},
});
// ... rest of form
}
Advanced Zod Schemas
import { z } from 'zod';
// Registration form with complex validation
const registrationSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Invalid email format'),
password: z
.string()
.min(1, 'Password is required')
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase letter')
.regex(/[a-z]/, 'Must contain lowercase letter')
.regex(/[0-9]/, 'Must contain a number'),
confirmPassword: z
.string()
.min(1, 'Please confirm your password'),
username: z
.string()
.min(3, 'Username must be at least 3 characters')
.max(20, 'Username must be at most 20 characters')
.regex(/^[a-zA-Z0-9_]+$/, 'Only letters, numbers, and underscores'),
age: z
.number({ invalid_type_error: 'Age must be a number' })
.int('Age must be a whole number')
.min(13, 'Must be at least 13 years old')
.max(120, 'Invalid age'),
website: z
.string()
.url('Invalid URL')
.optional()
.or(z.literal('')), // Allow empty string
terms: z
.boolean()
.refine(val => val === true, 'You must accept the terms'),
})
// Cross-field validation
.refine(data => data.password === data.confirmPassword, {
message: 'Passwords do not match',
path: ['confirmPassword'], // Show error on confirmPassword field
});
type RegistrationFormData = z.infer<typeof registrationSchema>;
Async Validation
// Check if username is available (async validation)
const usernameSchema = z
.string()
.min(3, 'Username must be at least 3 characters')
.refine(
async (username) => {
// Call API to check availability
const response = await fetch(`/api/check-username?u=${username}`);
const { available } = await response.json();
return available;
},
{ message: 'Username is already taken' }
);
// Use with mode: 'onBlur' to avoid checking on every keystroke
const { control } = useForm({
resolver: zodResolver(schema),
mode: 'onBlur', // Validate on blur instead of onChange
});
💡 Why Zod over React Hook Form Rules?
- Type inference: Schema automatically generates TypeScript types
- Reusable: Same schema can validate API responses, not just forms
- Composable: Build complex schemas from simple ones
- Cross-field validation: Easy password confirmation, date ranges, etc.
- Transform data: Coerce strings to numbers, trim whitespace, etc.
Displaying Validation Errors
Good error messages help users fix problems quickly. Here's how to display them effectively:
Inline Errors
// Simple inline error
{errors.email && (
<Text style={styles.errorText}>{errors.email.message}</Text>
)}
// Animated error with react-native-reanimated
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
{errors.email && (
<Animated.Text
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={styles.errorText}
>
{errors.email.message}
</Animated.Text>
)}
Error Summary
// Show all errors at the top of the form
function ErrorSummary({ errors }: { errors: FieldErrors }) {
const errorMessages = Object.values(errors)
.map(error => error?.message)
.filter(Boolean);
if (errorMessages.length === 0) return null;
return (
<View style={styles.errorSummary}>
<Text style={styles.errorTitle}>Please fix the following:</Text>
{errorMessages.map((message, index) => (
<Text key={index} style={styles.errorItem}>• {message}</Text>
))}
</View>
);
}
Input with Error State
interface FormInputProps {
label: string;
error?: string;
// ... other TextInput props
}
function FormInput({ label, error, ...props }: FormInputProps) {
return (
<View style={styles.field}>
<Text style={styles.label}>{label}</Text>
<TextInput
style={[
styles.input,
error && styles.inputError,
props.editable === false && styles.inputDisabled,
]}
placeholderTextColor="#999"
{...props}
/>
{error && (
<View style={styles.errorContainer}>
<Text style={styles.errorIcon}>⚠️</Text>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
field: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '600',
marginBottom: 6,
color: '#333',
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
fontSize: 16,
backgroundColor: '#fff',
},
inputError: {
borderColor: '#f44336',
borderWidth: 2,
},
inputDisabled: {
backgroundColor: '#f5f5f5',
color: '#999',
},
errorContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 4,
},
errorIcon: {
marginRight: 4,
},
errorText: {
color: '#f44336',
fontSize: 12,
},
});
Keyboard Handling
Proper keyboard handling is crucial for a good form experience on mobile. Let's cover the essential patterns.
KeyboardAvoidingView
import {
KeyboardAvoidingView,
Platform,
ScrollView
} from 'react-native';
function FormScreen() {
return (
<KeyboardAvoidingView
style={{ flex: 1 }}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
keyboardVerticalOffset={Platform.OS === 'ios' ? 64 : 0}
>
<ScrollView contentContainerStyle={styles.container}>
{/* Form fields */}
</ScrollView>
</KeyboardAvoidingView>
);
}
Dismissing the Keyboard
import {
Keyboard,
TouchableWithoutFeedback,
View
} from 'react-native';
// Dismiss keyboard when tapping outside inputs
function DismissKeyboard({ children }: { children: React.ReactNode }) {
return (
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View style={{ flex: 1 }}>{children}</View>
</TouchableWithoutFeedback>
);
}
// Usage
<DismissKeyboard>
<KeyboardAvoidingView ...>
{/* Form */}
</KeyboardAvoidingView>
</DismissKeyboard>
Moving Between Fields
import { useRef } from 'react';
import { TextInput } from 'react-native';
function MultiFieldForm() {
const emailRef = useRef<TextInput>(null);
const passwordRef = useRef<TextInput>(null);
const confirmRef = useRef<TextInput>(null);
return (
<View>
<TextInput
ref={emailRef}
placeholder="Email"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
blurOnSubmit={false}
/>
<TextInput
ref={passwordRef}
placeholder="Password"
secureTextEntry
returnKeyType="next"
onSubmitEditing={() => confirmRef.current?.focus()}
blurOnSubmit={false}
/>
<TextInput
ref={confirmRef}
placeholder="Confirm Password"
secureTextEntry
returnKeyType="done"
onSubmitEditing={handleSubmit} // Submit on done
/>
</View>
);
}
Complete Form with All Keyboard Handling
import { useRef } from 'react';
import { useForm, Controller } from 'react-hook-form';
import {
View,
Text,
TextInput,
KeyboardAvoidingView,
ScrollView,
TouchableWithoutFeedback,
Keyboard,
Platform,
Pressable,
StyleSheet,
} from 'react-native';
export default function CompleteForm() {
const passwordRef = useRef<TextInput>(null);
const { control, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data: any) => {
Keyboard.dismiss();
console.log(data);
};
return (
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<Controller
control={control}
name="email"
rules={{ required: 'Email required' }}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
style={styles.input}
placeholder="Email"
onBlur={onBlur}
onChangeText={onChange}
value={value}
keyboardType="email-address"
autoCapitalize="none"
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
blurOnSubmit={false}
/>
)}
/>
<Controller
control={control}
name="password"
rules={{ required: 'Password required' }}
render={({ field: { onChange, onBlur, value } }) => (
<TextInput
ref={passwordRef}
style={styles.input}
placeholder="Password"
onBlur={onBlur}
onChangeText={onChange}
value={value}
secureTextEntry
returnKeyType="done"
onSubmitEditing={handleSubmit(onSubmit)}
/>
)}
/>
<Pressable style={styles.button} onPress={handleSubmit(onSubmit)}>
<Text style={styles.buttonText}>Submit</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
</TouchableWithoutFeedback>
);
}
⚠️ keyboardShouldPersistTaps
Set keyboardShouldPersistTaps="handled" on ScrollView to allow pressing buttons while the keyboard is open. Without this, the first tap dismisses the keyboard instead of pressing the button.
Building Reusable Form Components
To avoid repeating the Controller boilerplate, create reusable form input components that integrate with React Hook Form.
FormInput Component
// components/form/FormInput.tsx
import { Controller, Control, FieldValues, Path } from 'react-hook-form';
import {
View,
Text,
TextInput,
TextInputProps,
StyleSheet
} from 'react-native';
interface FormInputProps<T extends FieldValues> extends Omit<TextInputProps, 'value' | 'onChangeText'> {
control: Control<T>;
name: Path<T>;
label: string;
rules?: object;
}
export function FormInput<T extends FieldValues>({
control,
name,
label,
rules,
...textInputProps
}: FormInputProps<T>) {
return (
<Controller
control={control}
name={name}
rules={rules}
render={({
field: { onChange, onBlur, value },
fieldState: { error }
}) => (
<View style={styles.container}>
<Text style={styles.label}>{label}</Text>
<TextInput
style={[
styles.input,
error && styles.inputError,
]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholderTextColor="#999"
{...textInputProps}
/>
{error && (
<Text style={styles.errorText}>{error.message}</Text>
)}
</View>
)}
/>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
label: {
fontSize: 14,
fontWeight: '600',
marginBottom: 6,
color: '#333',
},
input: {
borderWidth: 1,
borderColor: '#ddd',
borderRadius: 8,
padding: 12,
fontSize: 16,
backgroundColor: '#fff',
},
inputError: {
borderColor: '#f44336',
borderWidth: 2,
},
errorText: {
color: '#f44336',
fontSize: 12,
marginTop: 4,
},
});
Usage with the Reusable Component
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { FormInput } from '../components/form/FormInput';
const schema = z.object({
email: z.string().min(1, 'Required').email('Invalid email'),
password: z.string().min(8, 'Min 8 characters'),
});
type FormData = z.infer<typeof schema>;
export default function LoginForm() {
const { control, handleSubmit } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '' },
});
return (
<View>
<FormInput
control={control}
name="email"
label="Email"
placeholder="your@email.com"
keyboardType="email-address"
autoCapitalize="none"
/>
<FormInput
control={control}
name="password"
label="Password"
placeholder="••••••••"
secureTextEntry
/>
<Button title="Log In" onPress={handleSubmit(onSubmit)} />
</View>
);
}
More Reusable Components
// components/form/FormSelect.tsx
import { Controller, Control, FieldValues, Path } from 'react-hook-form';
import { View, Text, StyleSheet } from 'react-native';
import { Picker } from '@react-native-picker/picker';
interface Option {
label: string;
value: string;
}
interface FormSelectProps<T extends FieldValues> {
control: Control<T>;
name: Path<T>;
label: string;
options: Option[];
placeholder?: string;
}
export function FormSelect<T extends FieldValues>({
control,
name,
label,
options,
placeholder = 'Select an option',
}: FormSelectProps<T>) {
return (
<Controller
control={control}
name={name}
render={({
field: { onChange, value },
fieldState: { error }
}) => (
<View style={styles.container}>
<Text style={styles.label}>{label}</Text>
<View style={[styles.pickerContainer, error && styles.pickerError]}>
<Picker
selectedValue={value}
onValueChange={onChange}
>
<Picker.Item label={placeholder} value="" />
{options.map((option) => (
<Picker.Item
key={option.value}
label={option.label}
value={option.value}
/>
))}
</Picker>
</View>
{error && (
<Text style={styles.errorText}>{error.message}</Text>
)}
</View>
)}
/>
);
}
// components/form/FormCheckbox.tsx
import { Controller, Control, FieldValues, Path } from 'react-hook-form';
import { View, Text, Pressable, StyleSheet } from 'react-native';
interface FormCheckboxProps<T extends FieldValues> {
control: Control<T>;
name: Path<T>;
label: string;
}
export function FormCheckbox<T extends FieldValues>({
control,
name,
label,
}: FormCheckboxProps<T>) {
return (
<Controller
control={control}
name={name}
render={({
field: { onChange, value },
fieldState: { error }
}) => (
<View style={styles.container}>
<Pressable
style={styles.checkboxRow}
onPress={() => onChange(!value)}
>
<View style={[styles.checkbox, value && styles.checkboxChecked]}>
{value && <Text style={styles.checkmark}>✓</Text>}
</View>
<Text style={styles.checkboxLabel}>{label}</Text>
</Pressable>
{error && (
<Text style={styles.errorText}>{error.message}</Text>
)}
</View>
)}
/>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
checkboxRow: {
flexDirection: 'row',
alignItems: 'center',
},
checkbox: {
width: 24,
height: 24,
borderWidth: 2,
borderColor: '#ddd',
borderRadius: 4,
marginRight: 12,
alignItems: 'center',
justifyContent: 'center',
},
checkboxChecked: {
backgroundColor: '#667eea',
borderColor: '#667eea',
},
checkmark: {
color: 'white',
fontSize: 16,
fontWeight: 'bold',
},
checkboxLabel: {
fontSize: 14,
color: '#333',
},
errorText: {
color: '#f44336',
fontSize: 12,
marginTop: 4,
},
});
Real-World Form Examples
Complete Registration Form
import { useRef } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
View,
Text,
TextInput,
ScrollView,
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
Alert,
} from 'react-native';
import { FormInput } from '../components/form/FormInput';
import { FormCheckbox } from '../components/form/FormCheckbox';
const registrationSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
email: z.string().min(1, 'Email is required').email('Invalid email'),
password: z
.string()
.min(1, 'Password is required')
.min(8, 'At least 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase')
.regex(/[0-9]/, 'Must contain a number'),
confirmPassword: z.string().min(1, 'Please confirm password'),
acceptTerms: z.boolean().refine(val => val, '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 lastNameRef = useRef<TextInput>(null);
const emailRef = useRef<TextInput>(null);
const passwordRef = useRef<TextInput>(null);
const confirmPasswordRef = useRef<TextInput>(null);
const {
control,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<RegistrationData>({
resolver: zodResolver(registrationSchema),
defaultValues: {
firstName: '',
lastName: '',
email: '',
password: '',
confirmPassword: '',
acceptTerms: false,
},
});
const onSubmit = async (data: RegistrationData) => {
try {
// API call to register
await new Promise(resolve => setTimeout(resolve, 1500));
Alert.alert('Success', 'Account created successfully!');
} catch (error) {
Alert.alert('Error', 'Registration failed. Please try again.');
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
>
<Text style={styles.title}>Create Account</Text>
<View style={styles.row}>
<View style={styles.halfField}>
<FormInput
control={control}
name="firstName"
label="First Name"
placeholder="John"
returnKeyType="next"
onSubmitEditing={() => lastNameRef.current?.focus()}
blurOnSubmit={false}
/>
</View>
<View style={styles.halfField}>
<FormInput
control={control}
name="lastName"
label="Last Name"
placeholder="Doe"
ref={lastNameRef}
returnKeyType="next"
onSubmitEditing={() => emailRef.current?.focus()}
blurOnSubmit={false}
/>
</View>
</View>
<FormInput
control={control}
name="email"
label="Email"
placeholder="john@example.com"
keyboardType="email-address"
autoCapitalize="none"
ref={emailRef}
returnKeyType="next"
onSubmitEditing={() => passwordRef.current?.focus()}
blurOnSubmit={false}
/>
<FormInput
control={control}
name="password"
label="Password"
placeholder="••••••••"
secureTextEntry
ref={passwordRef}
returnKeyType="next"
onSubmitEditing={() => confirmPasswordRef.current?.focus()}
blurOnSubmit={false}
/>
<FormInput
control={control}
name="confirmPassword"
label="Confirm Password"
placeholder="••••••••"
secureTextEntry
ref={confirmPasswordRef}
returnKeyType="done"
/>
<FormCheckbox
control={control}
name="acceptTerms"
label="I accept the Terms of Service and Privacy Policy"
/>
<Pressable
style={[styles.button, isSubmitting && styles.buttonDisabled]}
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting}
>
<Text style={styles.buttonText}>
{isSubmitting ? 'Creating Account...' : 'Create Account'}
</Text>
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f5f5f5',
},
scrollContent: {
padding: 20,
},
title: {
fontSize: 28,
fontWeight: 'bold',
marginBottom: 24,
color: '#333',
},
row: {
flexDirection: 'row',
gap: 12,
},
halfField: {
flex: 1,
},
button: {
backgroundColor: '#667eea',
padding: 16,
borderRadius: 8,
alignItems: 'center',
marginTop: 8,
},
buttonDisabled: {
backgroundColor: '#bbb',
},
buttonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
});
Multi-Step Form
import { useState } from 'react';
import { useForm, FormProvider, useFormContext } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { View, Text, Pressable, StyleSheet } from 'react-native';
import { FormInput } from '../components/form/FormInput';
// Schema for each step
const step1Schema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(8, 'Min 8 characters'),
});
const step2Schema = z.object({
firstName: z.string().min(1, 'Required'),
lastName: z.string().min(1, 'Required'),
phone: z.string().min(10, 'Invalid phone number'),
});
const step3Schema = z.object({
address: z.string().min(1, 'Required'),
city: z.string().min(1, 'Required'),
zipCode: z.string().min(5, 'Invalid zip code'),
});
// Combined schema
const fullSchema = step1Schema.merge(step2Schema).merge(step3Schema);
type FormData = z.infer<typeof fullSchema>;
const stepSchemas = [step1Schema, step2Schema, step3Schema];
// Step components
function Step1() {
const { control } = useFormContext<FormData>();
return (
<>
<Text style={styles.stepTitle}>Account Details</Text>
<FormInput control={control} name="email" label="Email" keyboardType="email-address" />
<FormInput control={control} name="password" label="Password" secureTextEntry />
</>
);
}
function Step2() {
const { control } = useFormContext<FormData>();
return (
<>
<Text style={styles.stepTitle}>Personal Info</Text>
<FormInput control={control} name="firstName" label="First Name" />
<FormInput control={control} name="lastName" label="Last Name" />
<FormInput control={control} name="phone" label="Phone" keyboardType="phone-pad" />
</>
);
}
function Step3() {
const { control } = useFormContext<FormData>();
return (
<>
<Text style={styles.stepTitle}>Address</Text>
<FormInput control={control} name="address" label="Street Address" />
<FormInput control={control} name="city" label="City" />
<FormInput control={control} name="zipCode" label="Zip Code" keyboardType="number-pad" />
</>
);
}
const steps = [Step1, Step2, Step3];
export default function MultiStepForm() {
const [currentStep, setCurrentStep] = useState(0);
const methods = useForm<FormData>({
resolver: zodResolver(fullSchema),
mode: 'onChange',
defaultValues: {
email: '',
password: '',
firstName: '',
lastName: '',
phone: '',
address: '',
city: '',
zipCode: '',
},
});
const { trigger, handleSubmit, formState: { isSubmitting } } = methods;
const CurrentStepComponent = steps[currentStep];
// Get fields for current step to validate
const getStepFields = (step: number): (keyof FormData)[] => {
switch (step) {
case 0: return ['email', 'password'];
case 1: return ['firstName', 'lastName', 'phone'];
case 2: return ['address', 'city', 'zipCode'];
default: return [];
}
};
const handleNext = async () => {
const fields = getStepFields(currentStep);
const isValid = await trigger(fields);
if (isValid) {
setCurrentStep(prev => prev + 1);
}
};
const handleBack = () => {
setCurrentStep(prev => prev - 1);
};
const onSubmit = async (data: FormData) => {
console.log('Final data:', data);
// Submit to API...
};
const isLastStep = currentStep === steps.length - 1;
return (
<FormProvider {...methods}>
<View style={styles.container}>
{/* Progress indicator */}
<View style={styles.progressContainer}>
{steps.map((_, index) => (
<View
key={index}
style={[
styles.progressDot,
index <= currentStep && styles.progressDotActive,
]}
/>
))}
</View>
{/* Current step content */}
<View style={styles.stepContent}>
<CurrentStepComponent />
</View>
{/* Navigation buttons */}
<View style={styles.buttonRow}>
{currentStep > 0 && (
<Pressable style={styles.backButton} onPress={handleBack}>
<Text style={styles.backButtonText}>Back</Text>
</Pressable>
)}
<Pressable
style={[styles.nextButton, isSubmitting && styles.buttonDisabled]}
onPress={isLastStep ? handleSubmit(onSubmit) : handleNext}
disabled={isSubmitting}
>
<Text style={styles.nextButtonText}>
{isLastStep ? (isSubmitting ? 'Submitting...' : 'Submit') : 'Next'}
</Text>
</Pressable>
</View>
</View>
</FormProvider>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
progressContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
marginBottom: 24,
},
progressDot: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#ddd',
},
progressDotActive: {
backgroundColor: '#667eea',
},
stepContent: {
flex: 1,
},
stepTitle: {
fontSize: 24,
fontWeight: 'bold',
marginBottom: 20,
color: '#333',
},
buttonRow: {
flexDirection: 'row',
gap: 12,
},
backButton: {
flex: 1,
padding: 16,
borderRadius: 8,
alignItems: 'center',
backgroundColor: '#f5f5f5',
},
backButtonText: {
color: '#666',
fontSize: 16,
fontWeight: '600',
},
nextButton: {
flex: 2,
padding: 16,
borderRadius: 8,
alignItems: 'center',
backgroundColor: '#667eea',
},
nextButtonText: {
color: 'white',
fontSize: 16,
fontWeight: '600',
},
buttonDisabled: {
backgroundColor: '#bbb',
},
});
💡 Multi-Step Form Tips
- Use
FormProviderto share form context across step components - Validate only the current step's fields before moving forward
- Use
mode: 'onChange'for immediate validation feedback - Consider saving progress to AsyncStorage for long forms
- Show a clear progress indicator
Hands-On Exercises
Exercise 1: Contact Form with React Hook Form
Build a contact form with name, email, subject, and message fields.
Requirements:
- All fields required
- Email must be valid format
- Message must be at least 20 characters
- Use Zod for validation
- Show character count for message
- Disable submit while submitting
Show Hint
Use watch('message') to get the current message value for displaying the character count. Create a Zod schema with .min() validators for each field.
Show Solution
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
const contactSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().min(1, 'Email is required').email('Invalid email'),
subject: z.string().min(1, 'Subject is required'),
message: z.string().min(20, 'Message must be at least 20 characters'),
});
type ContactFormData = z.infer<typeof contactSchema>;
export default function ContactForm() {
const {
control,
handleSubmit,
watch,
formState: { errors, isSubmitting },
} = useForm<ContactFormData>({
resolver: zodResolver(contactSchema),
defaultValues: {
name: '',
email: '',
subject: '',
message: '',
},
});
const messageValue = watch('message');
const charCount = messageValue?.length || 0;
const onSubmit = async (data: ContactFormData) => {
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('Submitted:', data);
alert('Message sent!');
};
return (
<View style={styles.container}>
<Controller
control={control}
name="name"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<Text style={styles.label}>Name</Text>
<TextInput
style={[styles.input, errors.name && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholder="Your name"
/>
{errors.name && <Text style={styles.error}>{errors.name.message}</Text>}
</View>
)}
/>
<Controller
control={control}
name="email"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<Text style={styles.label}>Email</Text>
<TextInput
style={[styles.input, errors.email && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholder="your@email.com"
keyboardType="email-address"
autoCapitalize="none"
/>
{errors.email && <Text style={styles.error}>{errors.email.message}</Text>}
</View>
)}
/>
<Controller
control={control}
name="subject"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<Text style={styles.label}>Subject</Text>
<TextInput
style={[styles.input, errors.subject && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholder="What's this about?"
/>
{errors.subject && <Text style={styles.error}>{errors.subject.message}</Text>}
</View>
)}
/>
<Controller
control={control}
name="message"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<View style={styles.labelRow}>
<Text style={styles.label}>Message</Text>
<Text style={[styles.charCount, charCount < 20 && styles.charCountWarning]}>
{charCount}/20 min
</Text>
</View>
<TextInput
style={[styles.input, styles.textArea, errors.message && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholder="Your message..."
multiline
numberOfLines={5}
textAlignVertical="top"
/>
{errors.message && <Text style={styles.error}>{errors.message.message}</Text>}
</View>
)}
/>
<Pressable
style={[styles.button, isSubmitting && styles.buttonDisabled]}
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting}
>
<Text style={styles.buttonText}>
{isSubmitting ? 'Sending...' : 'Send Message'}
</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 20 },
field: { marginBottom: 16 },
labelRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
label: { fontSize: 14, fontWeight: '600', marginBottom: 6, color: '#333' },
charCount: { fontSize: 12, color: '#4CAF50' },
charCountWarning: { color: '#f44336' },
input: { borderWidth: 1, borderColor: '#ddd', borderRadius: 8, padding: 12, fontSize: 16 },
inputError: { borderColor: '#f44336', borderWidth: 2 },
textArea: { height: 120 },
error: { color: '#f44336', fontSize: 12, marginTop: 4 },
button: { backgroundColor: '#667eea', padding: 16, borderRadius: 8, alignItems: 'center' },
buttonDisabled: { backgroundColor: '#bbb' },
buttonText: { color: 'white', fontSize: 16, fontWeight: '600' },
});
Exercise 2: Payment Form with Card Validation
Create a credit card payment form with proper formatting and validation.
Requirements:
- Card number: 16 digits, formatted as XXXX XXXX XXXX XXXX
- Expiry: MM/YY format, must be future date
- CVV: 3-4 digits
- Cardholder name: Required
- Auto-format inputs as user types
Show Hint
Use a custom onChangeText that formats the value before passing to React Hook Form's onChange. For card number: value.replace(/\s/g, '').replace(/(.{4})/g, '$1 ').trim()
Show Solution
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { View, Text, TextInput, Pressable, StyleSheet } from 'react-native';
const paymentSchema = z.object({
cardNumber: z
.string()
.min(1, 'Card number is required')
.refine(val => val.replace(/\s/g, '').length === 16, 'Must be 16 digits'),
expiry: z
.string()
.min(1, 'Expiry is required')
.regex(/^\d{2}\/\d{2}$/, 'Format: MM/YY')
.refine(val => {
const [month, year] = val.split('/').map(Number);
const now = new Date();
const expiry = new Date(2000 + year, month - 1);
return expiry > now;
}, 'Card has expired'),
cvv: z
.string()
.min(3, 'CVV must be 3-4 digits')
.max(4, 'CVV must be 3-4 digits')
.regex(/^\d+$/, 'Must be numbers only'),
name: z.string().min(1, 'Cardholder name is required'),
});
type PaymentFormData = z.infer<typeof paymentSchema>;
// Formatting functions
const formatCardNumber = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 16);
return digits.replace(/(.{4})/g, '$1 ').trim();
};
const formatExpiry = (value: string) => {
const digits = value.replace(/\D/g, '').slice(0, 4);
if (digits.length >= 2) {
return digits.slice(0, 2) + '/' + digits.slice(2);
}
return digits;
};
export default function PaymentForm() {
const { control, handleSubmit, formState: { errors, isSubmitting } } = useForm<PaymentFormData>({
resolver: zodResolver(paymentSchema),
defaultValues: { cardNumber: '', expiry: '', cvv: '', name: '' },
});
const onSubmit = async (data: PaymentFormData) => {
console.log('Payment:', data);
};
return (
<View style={styles.container}>
<Text style={styles.title}>💳 Payment Details</Text>
<Controller
control={control}
name="cardNumber"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<Text style={styles.label}>Card Number</Text>
<TextInput
style={[styles.input, errors.cardNumber && styles.inputError]}
onBlur={onBlur}
onChangeText={(text) => onChange(formatCardNumber(text))}
value={value}
placeholder="1234 5678 9012 3456"
keyboardType="number-pad"
maxLength={19}
/>
{errors.cardNumber && <Text style={styles.error}>{errors.cardNumber.message}</Text>}
</View>
)}
/>
<View style={styles.row}>
<Controller
control={control}
name="expiry"
render={({ field: { onChange, onBlur, value } }) => (
<View style={[styles.field, styles.halfField]}>
<Text style={styles.label}>Expiry</Text>
<TextInput
style={[styles.input, errors.expiry && styles.inputError]}
onBlur={onBlur}
onChangeText={(text) => onChange(formatExpiry(text))}
value={value}
placeholder="MM/YY"
keyboardType="number-pad"
maxLength={5}
/>
{errors.expiry && <Text style={styles.error}>{errors.expiry.message}</Text>}
</View>
)}
/>
<Controller
control={control}
name="cvv"
render={({ field: { onChange, onBlur, value } }) => (
<View style={[styles.field, styles.halfField]}>
<Text style={styles.label}>CVV</Text>
<TextInput
style={[styles.input, errors.cvv && styles.inputError]}
onBlur={onBlur}
onChangeText={(text) => onChange(text.replace(/\D/g, '').slice(0, 4))}
value={value}
placeholder="123"
keyboardType="number-pad"
secureTextEntry
maxLength={4}
/>
{errors.cvv && <Text style={styles.error}>{errors.cvv.message}</Text>}
</View>
)}
/>
</View>
<Controller
control={control}
name="name"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<Text style={styles.label}>Cardholder Name</Text>
<TextInput
style={[styles.input, errors.name && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
placeholder="John Doe"
autoCapitalize="words"
/>
{errors.name && <Text style={styles.error}>{errors.name.message}</Text>}
</View>
)}
/>
<Pressable
style={[styles.button, isSubmitting && styles.buttonDisabled]}
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting}
>
<Text style={styles.buttonText}>{isSubmitting ? 'Processing...' : 'Pay Now'}</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 20 },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 },
field: { marginBottom: 16 },
row: { flexDirection: 'row', gap: 12 },
halfField: { flex: 1 },
label: { fontSize: 14, fontWeight: '600', marginBottom: 6, color: '#333' },
input: { borderWidth: 1, borderColor: '#ddd', borderRadius: 8, padding: 12, fontSize: 16 },
inputError: { borderColor: '#f44336', borderWidth: 2 },
error: { color: '#f44336', fontSize: 12, marginTop: 4 },
button: { backgroundColor: '#667eea', padding: 16, borderRadius: 8, alignItems: 'center', marginTop: 8 },
buttonDisabled: { backgroundColor: '#bbb' },
buttonText: { color: 'white', fontSize: 16, fontWeight: '600' },
});
Exercise 3: Profile Edit with Pre-filled Data
Build a profile edit form that loads existing user data and tracks changes.
Requirements:
- Load user data from a mock API on mount
- Pre-fill the form with existing values
- Show "Save" button only if form is dirty (has changes)
- Show loading state while fetching initial data
- Reset changes with a "Cancel" button
Show Hint
Use reset() from useForm to set values after fetching. Check formState.isDirty to conditionally show the save button. Call reset(getValues()) after successful save to mark form as clean.
Show Solution
import { useEffect, useState } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { View, Text, TextInput, Pressable, ActivityIndicator, StyleSheet } from 'react-native';
interface UserProfile {
name: string;
email: string;
bio: string;
website: string;
}
// Mock API
const fetchProfile = async (): Promise<UserProfile> => {
await new Promise(r => setTimeout(r, 1000));
return {
name: 'John Doe',
email: 'john@example.com',
bio: 'React Native developer',
website: 'https://johndoe.dev',
};
};
export default function ProfileEditForm() {
const [isLoading, setIsLoading] = useState(true);
const {
control,
handleSubmit,
reset,
formState: { errors, isDirty, isSubmitting },
} = useForm<UserProfile>({
defaultValues: { name: '', email: '', bio: '', website: '' },
});
// Fetch and populate form
useEffect(() => {
const loadProfile = async () => {
const profile = await fetchProfile();
reset(profile); // Pre-fill with fetched data
setIsLoading(false);
};
loadProfile();
}, [reset]);
const onSubmit = async (data: UserProfile) => {
await new Promise(r => setTimeout(r, 1000));
console.log('Saved:', data);
reset(data); // Mark form as clean after save
alert('Profile saved!');
};
const handleCancel = () => {
reset(); // Reset to last saved values
};
if (isLoading) {
return (
<View style={styles.loading}>
<ActivityIndicator size="large" color="#667eea" />
<Text>Loading profile...</Text>
</View>
);
}
return (
<View style={styles.container}>
<Text style={styles.title}>Edit Profile</Text>
<Controller
control={control}
name="name"
rules={{ required: 'Name is required' }}
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<Text style={styles.label}>Name</Text>
<TextInput
style={[styles.input, errors.name && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
/>
{errors.name && <Text style={styles.error}>{errors.name.message}</Text>}
</View>
)}
/>
<Controller
control={control}
name="email"
rules={{ required: 'Email is required' }}
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<Text style={styles.label}>Email</Text>
<TextInput
style={[styles.input, errors.email && styles.inputError]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
keyboardType="email-address"
autoCapitalize="none"
/>
{errors.email && <Text style={styles.error}>{errors.email.message}</Text>}
</View>
)}
/>
<Controller
control={control}
name="bio"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<Text style={styles.label}>Bio</Text>
<TextInput
style={[styles.input, styles.textArea]}
onBlur={onBlur}
onChangeText={onChange}
value={value}
multiline
numberOfLines={3}
/>
</View>
)}
/>
<Controller
control={control}
name="website"
render={({ field: { onChange, onBlur, value } }) => (
<View style={styles.field}>
<Text style={styles.label}>Website</Text>
<TextInput
style={styles.input}
onBlur={onBlur}
onChangeText={onChange}
value={value}
keyboardType="url"
autoCapitalize="none"
/>
</View>
)}
/>
{isDirty && (
<View style={styles.buttonRow}>
<Pressable style={styles.cancelButton} onPress={handleCancel}>
<Text style={styles.cancelButtonText}>Cancel</Text>
</Pressable>
<Pressable
style={[styles.saveButton, isSubmitting && styles.buttonDisabled]}
onPress={handleSubmit(onSubmit)}
disabled={isSubmitting}
>
<Text style={styles.saveButtonText}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</Text>
</Pressable>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { padding: 20 },
loading: { flex: 1, justifyContent: 'center', alignItems: 'center' },
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 },
field: { marginBottom: 16 },
label: { fontSize: 14, fontWeight: '600', marginBottom: 6 },
input: { borderWidth: 1, borderColor: '#ddd', borderRadius: 8, padding: 12, fontSize: 16 },
inputError: { borderColor: '#f44336' },
textArea: { height: 80, textAlignVertical: 'top' },
error: { color: '#f44336', fontSize: 12, marginTop: 4 },
buttonRow: { flexDirection: 'row', gap: 12, marginTop: 8 },
cancelButton: { flex: 1, padding: 16, borderRadius: 8, backgroundColor: '#f5f5f5', alignItems: 'center' },
cancelButtonText: { color: '#666', fontWeight: '600' },
saveButton: { flex: 2, padding: 16, borderRadius: 8, backgroundColor: '#667eea', alignItems: 'center' },
saveButtonText: { color: 'white', fontWeight: '600' },
buttonDisabled: { backgroundColor: '#bbb' },
});
Summary
Forms are a critical part of most mobile apps. Using the right tools and patterns makes them easier to build and maintain, while providing a great user experience.
🎯 Key Takeaways
- React Hook Form minimizes re-renders and reduces boilerplate
- Controller component is required for React Native TextInputs
- Zod provides type-safe validation with automatic TypeScript inference
- KeyboardAvoidingView prevents the keyboard from covering inputs
- keyboardShouldPersistTaps="handled" allows tapping buttons while keyboard is open
- Use refs and onSubmitEditing to move between fields
- Build reusable FormInput components to reduce repetition
- Use formState.isDirty to track if form has been modified
- FormProvider enables multi-step forms with shared state
This completes our Data Management and Networking module. You now have a comprehensive toolkit for managing data in React Native apps—from fetching server data to storing it locally, managing complex state, and building user-friendly forms.