Forms
All forms use react-hook-form with Zod validation via zodResolver.
For the dialog wrapper pattern (separating dialog state from form logic) see Component Patterns.
Schema + Types
Define the Zod schema first, then infer the TypeScript type:
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
role: z.enum(['admin', 'user', 'viewer']),
notify: z.boolean().default(false),
});
type FormData = z.infer<typeof schema>;Form Setup
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const form = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
email: '',
name: '',
role: 'user',
notify: false,
},
});Field Patterns
Simple Inputs
<Input
{...form.register('email')}
placeholder="Email"
/>
{form.formState.errors.email && (
<span className="text-sm text-destructive">
{form.formState.errors.email.message}
</span>
)}Controller for Complex Components
Use Controller for components that do not support ref forwarding:
import { Controller } from 'react-hook-form';
<Controller
control={form.control}
name="role"
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="user">User</SelectItem>
<SelectItem value="viewer">Viewer</SelectItem>
</Select>
)}
/>Submit with API Integration
const { createMachine } = useMachines();
const onSubmit = useCallback(async (data: FormData) => {
try {
await createMachine(data);
form.reset();
onSuccess?.();
} catch (error) {
form.setError('root', { message: 'Failed to create. Please try again.' });
}
}, [createMachine, form, onSuccess]);
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* fields */}
{form.formState.errors.root && (
<Alert variant="destructive">{form.formState.errors.root.message}</Alert>
)}
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Saving...' : 'Save'}
</Button>
</form>Dialog + Form Separation
Always separate dialog state from form logic:
// EditDialog.tsx — handles open/close
export function EditDialog({ open, onOpenChange }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<EditForm onSuccess={() => onOpenChange(false)} />
</DialogContent>
</Dialog>
);
}
// EditForm.tsx — handles form logic only
export function EditForm({ onSuccess }) {
const form = useForm<FormData>({ resolver: zodResolver(schema) });
// ... validation, submission, no dialog concerns
}Rules
- Define the Zod schema first, then derive the TypeScript type with
z.infer - Always use
zodResolver— never validate manually inonSubmit - Use
Controllerfor components that do not supportrefforwarding (Select, Combobox, DatePicker) - Set API errors on
rootwithform.setError('root', ...)— display at the form level - Separate dialog state from form logic — form component receives
onSuccess, notopen/onOpenChange
Last updated on