Forms
The house convention is React Hook Form + Zod, composed inside a Chakra
Drawer (occasionally a Dialog) as a *FormDrawer component — there are a dozen
of them under frontend/src/components/
(employees/EmployeeFormDrawer.tsx, users/UserFormDrawer.tsx,
timesheets/TimesheetFormDrawer.tsx, …).
The pattern, walked through
components/departments/DepartmentFormDrawer.tsx
is a compact, representative example.
1. Schema colocated with the component. The Zod schema lives at the top of the same file — not in a shared schemas module — and the form's value type is inferred from it:
const departmentFormSchema = z.object({
departmentName: z.string().min(1, 'Department name is required'),
description: z.string(),
parentDepartmentId: z.string().nullable(),
});
type DepartmentFormValues = z.infer<typeof departmentFormSchema>;
Note the schema models the form (selects hold string ids), and onSubmit
converts to the API payload type (Number(...), empty string → null). The
form shape and the DTO shape are allowed to differ.
2. useForm + zodResolver.
const { register, handleSubmit, reset, control, formState: { errors } } =
useForm<DepartmentFormValues>({
resolver: zodResolver(departmentFormSchema),
defaultValues: DEFAULT_VALUES,
});
An useEffect on [open, department] calls reset(...) — the same drawer
serves create and edit mode, so values are (re)seeded every time it opens with
either the entity being edited or the defaults.
3. The Drawer composition. Chakra v3 Drawer.Root → Portal → Drawer.Positioner → Drawer.Content with the form in Drawer.Body. The submit
button sits in Drawer.Footer outside the <form>, linked by
form="department-form" / the form's id — a detail worth copying, since it
keeps the footer sticky while still submitting natively. Simple inputs use
register(...); non-native widgets (the rich text editor, entity lookups) go
through RHF's <Controller>. Field errors render via Chakra's
Field.Root invalid={...} + Field.ErrorText.
4. Mutations + server-error mapping. Submission calls the domain's React
Query mutation hooks (state.md); success toasts and closes, and any
backend rejection (a 400/422 validation failure or a domain conflict) is
surfaced as a toast with the server's own message via
getApiErrorMessage:
createMutation.mutate(payload, {
onSuccess: () => { toaster.create({ title: 'Department created', type: 'success' }); onClose(); },
onError: error => {
toaster.create({ title: getApiErrorMessage(error, 'Failed to create department'), type: 'error' });
},
});
There is no per-field mapping of server-side validation errors back onto RHF
fields — the backend's ApiErrorResponse carries a single message
(api-layer.md), so client-side Zod is expected to
catch field-level problems first and server errors land as one toast.
The deliberate exception: Login
pages/auth/Login.tsx uses plain
useState for its two fields — no RHF, no Zod. The code doesn't spell out a
reason, but the shape of the page makes it unsurprising: there is nothing to
validate beyond required, and all the interesting error handling is
status-code driven (423 lockout / 428 ALTCHA / 429 rate-limit messages shown
verbatim, plus ALTCHA widget reset on 428) rather than field-level. Treat it as
the known exception, not a pattern to copy for data-entry forms.
Related
- CSV import "forms" (upload → preview → per-row create) are standardized in the
grid subsystem's
ImportModal— ag-grid.md. - Validation on the backend and the error contract: ../api/conventions.md.