({
resolver: zodResolver(formSchema),
defaultValues: {
email: '',
password: '',
},
});
// 4. Submit handler (type-safe!)
const onSubmit = async (data: FormData) => {
try {
await loginUser(data);
toast.success('Logged in successfully');
} catch (error) {
toast.error('Invalid credentials');
}
};
return (
);
}
```
**Key points:**
1. Define Zod schema first
2. Infer TypeScript type with `z.infer`
3. Use `zodResolver` in `useForm`
4. Register fields with `{...form.register('fieldName')}`
5. Show errors from `form.formState.errors`
6. Disable submit during submission
---
## Field Patterns
### Text Input
```tsx
{form.formState.errors.name && (
{form.formState.errors.name.message}
)}
```
---
### Textarea
```tsx
{form.formState.errors.description && (
{form.formState.errors.description.message}
)}
```
---
### Select
```tsx
{form.formState.errors.role && (
{form.formState.errors.role.message}
)}
```
---
### Checkbox
```tsx
form.setValue('acceptTerms', checked as boolean)}
/>
;
{
form.formState.errors.acceptTerms && (
{form.formState.errors.acceptTerms.message}
);
}
```
---
### Radio Group (Custom Pattern)
```tsx
```
---
## Validation with Zod
### Common Validation Patterns
```tsx
import { z } from 'zod';
// Email
z.string().email('Invalid email address');
// Min/max length
z.string().min(8, 'Minimum 8 characters').max(100, 'Maximum 100 characters');
// Required field
z.string().min(1, 'This field is required');
// Optional field
z.string().optional();
// Number with range
z.number().min(0).max(100);
// Number from string input
z.coerce.number().min(0);
// Enum
z.enum(['admin', 'user', 'guest'], {
errorMap: () => ({ message: 'Invalid role' }),
});
// URL
z.string().url('Invalid URL');
// Password with requirements
z.string()
.min(8, 'Password must be at least 8 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one number');
// Confirm password
z.object({
password: z.string().min(8),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
// Custom validation
z.string().refine((val) => !val.includes('badword'), {
message: 'Invalid input',
});
// Conditional fields
z.object({
role: z.enum(['admin', 'user']),
adminKey: z.string().optional(),
}).refine(
(data) => {
if (data.role === 'admin') {
return !!data.adminKey;
}
return true;
},
{
message: 'Admin key required for admin role',
path: ['adminKey'],
}
);
```
---
### Full Form Schema Example
```tsx
const userFormSchema = z.object({
// Required text
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
// Email
email: z.string().email('Invalid email address'),
// Optional phone
phone: z.string().optional(),
// Number
age: z.coerce.number().min(18, 'Must be 18 or older').max(120),
// Enum
role: z.enum(['admin', 'user', 'guest']),
// Boolean
acceptTerms: z.boolean().refine((val) => val === true, {
message: 'You must accept the terms',
}),
// Nested object
address: z.object({
street: z.string().min(1),
city: z.string().min(1),
zip: z.string().regex(/^\d{5}$/, 'Invalid ZIP code'),
}),
// Array
tags: z.array(z.string()).min(1, 'At least one tag required'),
});
type UserFormData = z.infer;
```
---
## Error Handling
### Field-Level Errors
```tsx
{form.formState.errors.email && (
{form.formState.errors.email.message}
)}
```
**Accessibility notes:**
- Use `aria-invalid` to indicate error state
- Use `aria-describedby` to link error message
- Error ID format: `{fieldName}-error`
---
### Form-Level Errors
```tsx
const onSubmit = async (data: FormData) => {
try {
await submitForm(data);
} catch (error) {
// Set form-level error
form.setError('root', {
type: 'server',
message: error.message || 'Something went wrong',
});
}
};
// Display form-level error
{
form.formState.errors.root && (
{form.formState.errors.root.message}
);
}
```
---
### Server Validation Errors
```tsx
const onSubmit = async (data: FormData) => {
try {
await createUser(data);
} catch (error) {
if (error.response?.data?.errors) {
// Map server errors to form fields
const serverErrors = error.response.data.errors;
Object.keys(serverErrors).forEach((field) => {
form.setError(field as keyof FormData, {
type: 'server',
message: serverErrors[field],
});
});
} else {
// Generic error
form.setError('root', {
type: 'server',
message: 'Failed to create user',
});
}
}
};
```
---
## Loading & Submit States
### Basic Loading State
```tsx
```
---
### Disable All Fields During Submit
```tsx
const isDisabled = form.formState.isSubmitting;
```
---
### Loading with Toast
```tsx
const onSubmit = async (data: FormData) => {
const loadingToast = toast.loading('Creating user...');
try {
await createUser(data);
toast.success('User created successfully', { id: loadingToast });
router.push('/users');
} catch (error) {
toast.error('Failed to create user', { id: loadingToast });
}
};
```
---
## Form Layouts
### Centered Form (Login, Signup)
```tsx
Sign In
Enter your credentials to continue
```
---
### Two-Column Form
```tsx
```
---
### Form with Sections
```tsx
```
---
## Advanced Patterns
### Dynamic Fields (Array)
```tsx
import { useFieldArray } from 'react-hook-form';
const schema = z.object({
items: z
.array(
z.object({
name: z.string().min(1),
quantity: z.coerce.number().min(1),
})
)
.min(1, 'At least one item required'),
});
function DynamicForm() {
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
items: [{ name: '', quantity: 1 }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'items',
});
return (
);
}
```
---
### Conditional Fields
```tsx
const schema = z
.object({
role: z.enum(['user', 'admin']),
adminKey: z.string().optional(),
})
.refine(
(data) => {
if (data.role === 'admin') {
return !!data.adminKey;
}
return true;
},
{
message: 'Admin key required',
path: ['adminKey'],
}
);
function ConditionalForm() {
const form = useForm({ resolver: zodResolver(schema) });
const role = form.watch('role');
return (
);
}
```
---
### File Upload
```tsx
const schema = z.object({
file: z.instanceof(FileList).refine((files) => files.length > 0, {
message: 'File is required',
}),
});
;
const onSubmit = (data: FormData) => {
const file = data.file[0]; // FileList -> File
const formData = new FormData();
formData.append('file', file);
// Upload formData
};
```
---
## Form Checklist
Before shipping a form, verify:
### Functionality
- [ ] All fields register correctly
- [ ] Validation works (test invalid inputs)
- [ ] Submit handler fires
- [ ] Loading state works
- [ ] Error messages display
- [ ] Success case redirects/shows success
### Accessibility
- [ ] Labels associated with inputs (`htmlFor` + `id`)
- [ ] Error messages use `aria-describedby`
- [ ] Invalid inputs have `aria-invalid`
- [ ] Focus order is logical (Tab through form)
- [ ] Submit button disabled during submission
### UX
- [ ] Field errors appear on blur or submit
- [ ] Loading state prevents double-submit
- [ ] Success message or redirect on success
- [ ] Cancel button clears form or navigates away
- [ ] Mobile-friendly (responsive layout)
---
## Next Steps
- **Interactive Examples**: [Form examples](/dev/forms)
- **Components**: [Form components](./02-components.md#form-components)
- **Accessibility**: [Form accessibility](./07-accessibility.md#forms)
---
**Related Documentation:**
- [Components](./02-components.md) - Input, Label, Button, Select
- [Layouts](./03-layouts.md) - Form layout patterns
- [Accessibility](./07-accessibility.md) - ARIA attributes for forms
**External Resources:**
- [react-hook-form Documentation](https://react-hook-form.com)
- [Zod Documentation](https://zod.dev)
**Last Updated**: November 2, 2025