Forms & Validation
This boilerplate uses React Hook Form with Zod for type-safe forms and validation, providing excellent developer experience and performance.
Why This Stack?
📝 React Hook Form
Performant, flexible forms with minimal re-renders
✅ Zod
TypeScript-first schema validation with type inference
🎯 Type Safety
Full TypeScript support from schema to form data
⚡ Performance
Efficient validation and minimal re-renders
Basic Form Example
typescript
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { View, TextInput, Text, Pressable } from 'react-native';
// 1. Define validation schema
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
// 2. Infer TypeScript type
type LoginForm = z.infer<typeof loginSchema>;
export function LoginScreen() {
// 3. Setup form with validation
const { control, handleSubmit, formState: { errors } } = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
});
// 4. Handle submission
const onSubmit = (data: LoginForm) => {
console.log('Valid form data:', data);
// Call API, etc.
};
return (
<View className="p-4 gap-4">
{/* Email Field */}
<View>
<Text className="text-sm font-medium text-white mb-2">Email</Text>
<Controller
control={control}
name="email"
render={({ field: { onChange, value } }) => (
<TextInput
value={value}
onChangeText={onChange}
placeholder="you@example.com"
keyboardType="email-address"
autoCapitalize="none"
className="bg-white/10 border border-white/20 rounded-lg px-4 py-3 text-white"
/>
)}
/>
{errors.email && (
<Text className="text-red-400 text-sm mt-1">
{errors.email.message}
</Text>
)}
</View>
{/* Password Field */}
<View>
<Text className="text-sm font-medium text-white mb-2">Password</Text>
<Controller
control={control}
name="password"
render={({ field: { onChange, value } }) => (
<TextInput
value={value}
onChangeText={onChange}
placeholder="••••••••"
secureTextEntry
className="bg-white/10 border border-white/20 rounded-lg px-4 py-3 text-white"
/>
)}
/>
{errors.password && (
<Text className="text-red-400 text-sm mt-1">
{errors.password.message}
</Text>
)}
</View>
{/* Submit Button */}
<Pressable
onPress={handleSubmit(onSubmit)}
className="bg-blue-500 rounded-lg py-3 mt-4"
>
<Text className="text-white font-semibold text-center">Login</Text>
</Pressable>
</View>
);
}Validation Schemas
Create reusable schemas in your domain layer:
src/domain/schemas/user.schema.ts
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().email('Invalid email'),
password: z.string().min(6, 'Min 6 characters'),
});
export const registerSchema = z.object({
name: z.string().min(2, 'Name is required'),
email: z.string().email('Invalid email'),
password: z.string()
.min(8, 'Min 8 characters')
.regex(/[A-Z]/, 'Must contain uppercase')
.regex(/[a-z]/, 'Must contain lowercase')
.regex(/[0-9]/, 'Must contain number'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
export const profileSchema = z.object({
name: z.string().min(2, 'Name is required'),
bio: z.string().max(160, 'Bio is too long').optional(),
website: z.string().url('Invalid URL').optional().or(z.literal('')),
dateOfBirth: z.date().max(new Date(), 'Invalid date'),
});
export const taskSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
priority: z.enum(['low', 'medium', 'high']),
dueDate: z.date().min(new Date(), 'Date must be in future').optional(),
tags: z.array(z.string()),
});
// Export inferred types
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterInput = z.infer<typeof registerSchema>;
export type ProfileInput = z.infer<typeof profileSchema>;
export type TaskInput = z.infer<typeof taskSchema>;Form in ViewModel
Keep form logic in ViewModels for cleaner code:
src/presentation/viewmodels/useLoginViewModel.ts
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation } from '@tanstack/react-query';
import { router } from 'expo-router';
import { loginSchema, type LoginInput } from '@/domain/schemas/user.schema';
import { authApi } from '@/data/api/endpoints/auth';
import { useAuthStore } from '@/stores/auth.store';
export function useLoginViewModel() {
const login = useAuthStore(state => state.login);
const form = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: '',
password: '',
},
});
const loginMutation = useMutation({
mutationFn: authApi.login,
onSuccess: (data) => {
login(data.token, data.user);
router.replace('/home');
},
onError: (error: Error) => {
form.setError('root', {
message: error.message,
});
},
});
const handleSubmit = form.handleSubmit((data) => {
loginMutation.mutate(data);
});
return {
form,
handleSubmit,
isLoading: loginMutation.isPending,
error: form.formState.errors.root?.message,
};
}View using ViewModel
import { useLoginViewModel } from '@/presentation/viewmodels/useLoginViewModel';
export function LoginScreen() {
const { form, handleSubmit, isLoading, error } = useLoginViewModel();
const { control, formState: { errors } } = form;
return (
<View className="p-4">
{error && (
<View className="bg-red-500/10 border border-red-500 rounded-lg p-3 mb-4">
<Text className="text-red-500">{error}</Text>
</View>
)}
{/* Form fields... */}
<Pressable
onPress={handleSubmit}
disabled={isLoading}
className="bg-blue-500 rounded-lg py-3"
>
<Text className="text-white font-semibold text-center">
{isLoading ? 'Loading...' : 'Login'}
</Text>
</Pressable>
</View>
);
}Reusable Form Components
src/presentation/components/FormInput.tsx
import { Controller, Control, FieldPath, FieldValues } from 'react-hook-form';
import { View, Text, TextInput, TextInputProps } from 'react-native';
import { cn } from '@/utils/cn';
interface FormInputProps<T extends FieldValues> extends TextInputProps {
control: Control<T>;
name: FieldPath<T>;
label: string;
error?: string;
}
export function FormInput<T extends FieldValues>({
control,
name,
label,
error,
...inputProps
}: FormInputProps<T>) {
return (
<View>
<Text className="text-sm font-medium text-white mb-2">{label}</Text>
<Controller
control={control}
name={name}
render={({ field: { onChange, value } }) => (
<TextInput
value={value as string}
onChangeText={onChange}
className={cn(
'bg-white/10 border rounded-lg px-4 py-3 text-white',
error ? 'border-red-500' : 'border-white/20'
)}
placeholderTextColor="#94a3b8"
{...inputProps}
/>
)}
/>
{error && (
<Text className="text-red-400 text-sm mt-1">{error}</Text>
)}
</View>
);
}
// Usage
<FormInput
control={control}
name="email"
label="Email"
error={errors.email?.message}
keyboardType="email-address"
autoCapitalize="none"
/>Select/Dropdown
typescript
import { Controller } from 'react-hook-form';
import { View, Text, Pressable } from 'react-native';
const priorities = ['low', 'medium', 'high'] as const;
<Controller
control={control}
name="priority"
render={({ field: { onChange, value } }) => (
<View className="flex-row gap-2">
{priorities.map((priority) => (
<Pressable
key={priority}
onPress={() => onChange(priority)}
className={cn(
'px-4 py-2 rounded-lg',
value === priority
? 'bg-blue-500'
: 'bg-white/10 border border-white/20'
)}
>
<Text className="text-white capitalize">{priority}</Text>
</Pressable>
))}
</View>
)}
/>Checkbox/Switch
typescript
import { Controller } from 'react-hook-form';
import { View, Text, Switch } from 'react-native';
<Controller
control={control}
name="rememberMe"
render={({ field: { onChange, value } }) => (
<View className="flex-row items-center justify-between">
<Text className="text-white">Remember me</Text>
<Switch
value={value}
onValueChange={onChange}
trackColor={{ false: '#767577', true: '#3b82f6' }}
/>
</View>
)}
/>Date Picker
typescript
import { Controller } from 'react-hook-form';
import DateTimePicker from '@react-native-community/datetimepicker';
import { View, Text, Pressable } from 'react-native';
import { format } from 'date-fns';
<Controller
control={control}
name="dueDate"
render={({ field: { onChange, value } }) => (
<View>
<Text className="text-sm font-medium text-white mb-2">Due Date</Text>
<Pressable
onPress={() => setShowPicker(true)}
className="bg-white/10 border border-white/20 rounded-lg px-4 py-3"
>
<Text className="text-white">
{value ? format(value, 'PPP') : 'Select date'}
</Text>
</Pressable>
{showPicker && (
<DateTimePicker
value={value || new Date()}
mode="date"
onChange={(event, date) => {
setShowPicker(false);
if (date) onChange(date);
}}
/>
)}
</View>
)}
/>Dynamic Fields
Use useFieldArray for dynamic lists:
typescript
import { useForm, useFieldArray } from 'react-hook-form';
const form = useForm({
defaultValues: {
tags: [{ value: '' }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'tags',
});
return (
<View>
{fields.map((field, index) => (
<View key={field.id} className="flex-row gap-2">
<Controller
control={form.control}
name={`tags.${index}.value`}
render={({ field }) => (
<TextInput {...field} className="flex-1" />
)}
/>
<Pressable onPress={() => remove(index)}>
<Text className="text-red-500">Remove</Text>
</Pressable>
</View>
))}
<Pressable onPress={() => append({ value: '' })}>
<Text className="text-blue-500">Add Tag</Text>
</Pressable>
</View>
);Form State
typescript
const { formState } = useForm();
// Check validation status
const isValid = formState.isValid;
const isDirty = formState.isDirty; // Has user changed anything?
const isSubmitting = formState.isSubmitting;
const isSubmitted = formState.isSubmitted;
// Touched fields
const touchedFields = formState.touchedFields;
// All errors
const errors = formState.errors;
// Disable submit if invalid
<Pressable
onPress={handleSubmit(onSubmit)}
disabled={!isValid || isSubmitting}
className={cn(
'bg-blue-500 rounded-lg py-3',
(!isValid || isSubmitting) && 'opacity-50'
)}
>
<Text className="text-white font-semibold text-center">
{isSubmitting ? 'Submitting...' : 'Submit'}
</Text>
</Pressable>Custom Validation
typescript
const schema = z.object({
username: z.string()
.min(3, 'Min 3 characters')
.refine(
async (username) => {
// Check if username is available
const available = await checkUsernameAvailability(username);
return available;
},
{ message: 'Username is taken' }
),
email: z.string()
.email('Invalid email')
.refine(
(email) => !email.endsWith('@tempmail.com'),
{ message: 'Temporary emails not allowed' }
),
});Best Practices
✅ Define schemas in domain layer
Keep validation logic separate from UI
✅ Use ViewModels for form logic
Keep Views simple and focused on rendering
✅ Create reusable components
Extract common form inputs into components
✅ Show errors clearly
Display validation errors immediately and clearly
✅ Disable submit when invalid
Prevent submission of invalid forms
Zod schemas provide both runtime validation and TypeScript types - you get type safety for free!