Form Components
When to Use
Use when building Input, Select, Checkbox, Textarea, or any form field component. Form components have unique requirements: validation state, error display, accessibility linking, and library integration.
Decision
| If you need... | Use... | Why |
|---|---|---|
| Unmanaged form state (large forms) | react-hook-form with register |
Uncontrolled inputs; minimal re-renders; schema validation |
| Controlled input in design system | value + onChange with forwardRef |
Works with react-hook-form Controller; also works standalone |
| Accessible field (label + input + error) | Compound FormField component |
Links label, input, and error via id/aria-describedby |
| Schema validation | Zod + react-hook-form zodResolver |
Type-safe validation; errors derived from schema, not ad-hoc logic |
| Error display | aria-describedby on input → error id |
Screen readers announce errors on focus; no additional JS needed |
Pattern
Accessible FormField compound component:
const FormFieldContext = React.createContext<{ id: string } | null>(null);
export function FormField({ children }: { children: React.ReactNode }) {
const id = React.useId();
return <FormFieldContext.Provider value={{ id }}>{children}</FormFieldContext.Provider>;
}
FormField.Label = function FormLabel({ children }: { children: React.ReactNode }) {
const { id } = React.useContext(FormFieldContext)!;
return <label htmlFor={id} className="block text-sm font-medium">{children}</label>;
};
FormField.Input = React.forwardRef<HTMLInputElement, InputProps & { errorId?: string }>(
({ errorId, className, ...props }, ref) => {
const { id } = React.useContext(FormFieldContext)!;
return (
<input
id={id}
ref={ref}
aria-describedby={errorId}
aria-invalid={!!errorId}
className={cn('border rounded px-3 py-2', errorId && 'border-destructive', className)}
{...props}
/>
);
}
);
// role="alert" for submit-time errors; role="status" for live validation
FormField.Error = function FormError({ children, id, live = 'polite' }: { children?: React.ReactNode; id: string; live?: 'polite' | 'assertive' }) {
if (!children) return null;
return <p id={id} role={live === 'assertive' ? 'alert' : 'status'} className="text-sm text-destructive mt-1">{children}</p>;
};
react-hook-form integration:
const { control, handleSubmit } = useForm<FormValues>({ resolver: zodResolver(schema) });
<Controller name="email" control={control} render={({ field, fieldState }) => (
<FormField>
<FormField.Label>Email</FormField.Label>
<FormField.Input {...field} errorId={fieldState.error ? 'email-error' : undefined} />
<FormField.Error id="email-error">{fieldState.error?.message}</FormField.Error>
</FormField>
)} />
Common Mistakes
- Wrong: Controlled inputs without
forwardRef→ Right: react-hook-form'sregisteruses ref internally; inputs without refs fall back to uncontrolled behavior - Wrong: Managing form state with
useStateper field → Right: One re-render per keystroke across the whole form; use react-hook-form - Wrong: Displaying errors without
aria-describedby→ Right: Screen reader users see the visual error but don't know it's associated with the field - Wrong: Using
requiredHTML attribute instead of schema validation → Right: Doesn't integrate with react-hook-form's error system; validation displays inconsistently - Wrong: Manually crafted IDs for label/input linking → Right: Use
React.useId(); manually crafted IDs collide in pages with multiple forms
See Also
- Component Organization
- Layout Components
- Reference: react-hook-form — Advanced Usage
- Reference: react-hook-form — Controller
- Reference: Zod