Files
Felipe Cardoso 96df7edf88 Refactor useAuth hook, settings components, and docs for formatting and readability improvements
- Consolidated multi-line arguments into single lines where appropriate in `useAuth`.
- Improved spacing and readability in data processing across components (`ProfileSettingsForm`, `PasswordChangeForm`, `SessionCard`).
- Applied consistent table and markdown formatting in design system docs (e.g., `README.md`, `08-ai-guidelines.md`, `00-quick-start.md`).
- Updated code snippets to ensure adherence to Prettier rules and streamlined JSX structures.
2025-11-10 11:03:45 +01:00

811 lines
19 KiB
Markdown

# Forms Guide
**Master form patterns with react-hook-form + Zod validation**: Learn field layouts, error handling, loading states, and accessibility best practices for bulletproof forms.
---
## Table of Contents
1. [Form Architecture](#form-architecture)
2. [Basic Form Pattern](#basic-form-pattern)
3. [Field Patterns](#field-patterns)
4. [Validation with Zod](#validation-with-zod)
5. [Error Handling](#error-handling)
6. [Loading & Submit States](#loading--submit-states)
7. [Form Layouts](#form-layouts)
8. [Advanced Patterns](#advanced-patterns)
---
## Form Architecture
### Technology Stack
- **react-hook-form** - Form state management, validation
- **Zod** - Schema validation
- **@hookform/resolvers** - Zod resolver for react-hook-form
- **shadcn/ui components** - Input, Label, Button, etc.
**Why this stack?**
- ✅ Type-safe validation (TypeScript + Zod)
- ✅ Minimal re-renders (react-hook-form)
- ✅ Accessible by default (shadcn/ui)
- ✅ Easy error handling
- ✅ Built-in loading states
---
### Form Decision Tree
```
Need a form?
├─ Single field (search, filter)?
│ └─> Use uncontrolled input with onChange
│ <Input onChange={(e) => setQuery(e.target.value)} />
├─ Simple form (1-3 fields, no complex validation)?
│ └─> Use react-hook-form without Zod
│ const form = useForm();
└─ Complex form (4+ fields, validation, async submit)?
└─> Use react-hook-form + Zod
const form = useForm({ resolver: zodResolver(schema) });
```
---
## Basic Form Pattern
### Minimal Form (No Validation)
```tsx
import { useForm } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
interface FormData {
email: string;
}
export function SimpleForm() {
const form = useForm<FormData>();
const onSubmit = (data: FormData) => {
console.log(data);
};
return (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" {...form.register('email')} />
</div>
<Button type="submit">Submit</Button>
</form>
);
}
```
---
### Complete Form Pattern (with Zod)
```tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
// 1. Define validation schema
const formSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(8, 'Password must be at least 8 characters'),
});
// 2. Infer TypeScript type from schema
type FormData = z.infer<typeof formSchema>;
export function LoginForm() {
// 3. Initialize form with Zod resolver
const form = useForm<FormData>({
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 (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Email field */}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
{...form.register('email')}
aria-invalid={!!form.formState.errors.email}
aria-describedby={form.formState.errors.email ? 'email-error' : undefined}
/>
{form.formState.errors.email && (
<p id="email-error" className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
{/* Password field */}
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
{...form.register('password')}
aria-invalid={!!form.formState.errors.password}
aria-describedby={form.formState.errors.password ? 'password-error' : undefined}
/>
{form.formState.errors.password && (
<p id="password-error" className="text-sm text-destructive">
{form.formState.errors.password.message}
</p>
)}
</div>
{/* Submit button */}
<Button type="submit" disabled={form.formState.isSubmitting} className="w-full">
{form.formState.isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
</form>
);
}
```
**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
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
{...form.register('name')}
aria-invalid={!!form.formState.errors.name}
aria-describedby={form.formState.errors.name ? 'name-error' : undefined}
/>
{form.formState.errors.name && (
<p id="name-error" className="text-sm text-destructive">
{form.formState.errors.name.message}
</p>
)}
</div>
```
---
### Textarea
```tsx
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea id="description" rows={4} {...form.register('description')} />
{form.formState.errors.description && (
<p className="text-sm text-destructive">{form.formState.errors.description.message}</p>
)}
</div>
```
---
### Select
```tsx
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={form.watch('role')} onValueChange={(value) => form.setValue('role', value)}>
<SelectTrigger id="role">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="guest">Guest</SelectItem>
</SelectContent>
</Select>
{form.formState.errors.role && (
<p className="text-sm text-destructive">{form.formState.errors.role.message}</p>
)}
</div>
```
---
### Checkbox
```tsx
<div className="flex items-center space-x-2">
<Checkbox
id="terms"
checked={form.watch('acceptTerms')}
onCheckedChange={(checked) => form.setValue('acceptTerms', checked as boolean)}
/>
<Label htmlFor="terms" className="text-sm font-normal">
I accept the terms and conditions
</Label>
</div>;
{
form.formState.errors.acceptTerms && (
<p className="text-sm text-destructive">{form.formState.errors.acceptTerms.message}</p>
);
}
```
---
### Radio Group (Custom Pattern)
```tsx
<div className="space-y-2">
<Label>Notification Method</Label>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<input type="radio" id="email" value="email" {...form.register('notificationMethod')} />
<Label htmlFor="email" className="font-normal">
Email
</Label>
</div>
<div className="flex items-center space-x-2">
<input type="radio" id="sms" value="sms" {...form.register('notificationMethod')} />
<Label htmlFor="sms" className="font-normal">
SMS
</Label>
</div>
</div>
</div>
```
---
## 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<typeof userFormSchema>;
```
---
## Error Handling
### Field-Level Errors
```tsx
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
{...form.register('email')}
className={form.formState.errors.email ? 'border-destructive' : ''}
aria-invalid={!!form.formState.errors.email}
aria-describedby={form.formState.errors.email ? 'email-error' : undefined}
/>
{form.formState.errors.email && (
<p id="email-error" className="text-sm text-destructive">
{form.formState.errors.email.message}
</p>
)}
</div>
```
**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 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{form.formState.errors.root.message}</AlertDescription>
</Alert>
);
}
```
---
### 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
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Save Changes'}
</Button>
```
---
### Disable All Fields During Submit
```tsx
const isDisabled = form.formState.isSubmitting;
<Input
{...form.register('name')}
disabled={isDisabled}
/>
<Button type="submit" disabled={isDisabled}>
{isDisabled ? 'Submitting...' : 'Submit'}
</Button>
```
---
### 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
<div className="container mx-auto px-4 py-8">
<Card className="max-w-md mx-auto">
<CardHeader>
<CardTitle>Sign In</CardTitle>
<CardDescription>Enter your credentials to continue</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Form fields */}
<Button type="submit" className="w-full">
Sign In
</Button>
</form>
</CardContent>
</Card>
</div>
```
---
### Two-Column Form
```tsx
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Row 1: Two columns */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="firstName">First Name</Label>
<Input id="firstName" {...form.register('firstName')} />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Last Name</Label>
<Input id="lastName" {...form.register('lastName')} />
</div>
</div>
{/* Row 2: Full width */}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" {...form.register('email')} />
</div>
<Button type="submit">Save</Button>
</form>
```
---
### Form with Sections
```tsx
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
{/* Section 1 */}
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">Personal Information</h3>
<p className="text-sm text-muted-foreground">Basic details about you</p>
</div>
<Separator />
<div className="space-y-4">{/* Fields */}</div>
</div>
{/* Section 2 */}
<div className="space-y-4">
<div>
<h3 className="text-lg font-semibold">Account Settings</h3>
<p className="text-sm text-muted-foreground">Configure your account preferences</p>
</div>
<Separator />
<div className="space-y-4">{/* Fields */}</div>
</div>
<div className="flex justify-end gap-4">
<Button type="button" variant="outline">
Cancel
</Button>
<Button type="submit">Save Changes</Button>
</div>
</form>
```
---
## 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 (
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="flex gap-4">
<Input {...form.register(`items.${index}.name`)} placeholder="Item name" />
<Input
type="number"
{...form.register(`items.${index}.quantity`)}
placeholder="Quantity"
/>
<Button type="button" variant="destructive" onClick={() => remove(index)}>
Remove
</Button>
</div>
))}
<Button type="button" variant="outline" onClick={() => append({ name: '', quantity: 1 })}>
Add Item
</Button>
<Button type="submit">Submit</Button>
</form>
);
}
```
---
### 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 (
<form className="space-y-4">
<Select value={role} onValueChange={(val) => form.setValue('role', val as any)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">User</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</Select>
{role === 'admin' && <Input {...form.register('adminKey')} placeholder="Admin Key" />}
</form>
);
}
```
---
### File Upload
```tsx
const schema = z.object({
file: z.instanceof(FileList).refine((files) => files.length > 0, {
message: 'File is required',
}),
});
<input type="file" {...form.register('file')} accept="image/*" />;
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