Skip to main content

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.