Skip to Content

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

  1. Define the Zod schema first, then derive the TypeScript type with z.infer
  2. Always use zodResolver — never validate manually in onSubmit
  3. Use Controller for components that do not support ref forwarding (Select, Combobox, DatePicker)
  4. Set API errors on root with form.setError('root', ...) — display at the form level
  5. Separate dialog state from form logic — form component receives onSuccess, not open/onOpenChange
Last updated on