Skip to main content

21 — Forms, decoded through one drawer

Read this first: every create/edit form in this repo is the same drawer wearing different fields — a dozen *FormDrawer components that all share one skeleton. This lesson reads the biggest one, EmployeeFormDrawer, from its props to the toast that reports a server rejection. By the end you can write the thirteenth without inventing anything.

Time: about 25 minutes. Assumes lesson 20.

Two props where lesson 12 needed one​

Lesson 12 taught the id-prop rule: the detail drawer takes only employeeId: number | null, because null means closed and one variable cannot desync. The form drawer takes two props for the same job:

interface EmployeeFormDrawerProps {
open: boolean;
/** null = create mode. In edit mode the full record is fetched here, since the grid row
* carries only the projected columns. */
employeeId: number | null;
onClose: () => void;
onSuccess: () => void;
}

The doc comment explains why the rule bends: null is taken. In a form drawer, null means create mode — the drawer is open with empty fields. So visibility needs its own open boolean, and the id is left to select the mode:

const isEditMode = employeeId !== null;
const { data: employee = null } = useEmployee(open ? employeeId : null);

The second line does two things at once. In edit mode it re-fetches the full record — the grid row is a projection, the same rent lesson 12's detail drawer paid. And the open ? … : null gate means a closed drawer holding a leftover editingId fetches nothing. The id drives the title and the submit button too: {isEditMode ? 'Edit Employee' : 'Add Employee'}, {isEditMode ? 'Save Changes' : 'Create Employee'}. One component, two modes, zero duplicated markup.

The schema is the form​

Validation lives at the top of the same file, not in a shared schemas module:

const employeeFormSchema = z.object({
firstName: z.string().min(1, 'First name is required'),
lastName: z.string().min(1, 'Last name is required'),
middleName: z.string().optional(),
// … dates, address, phone, four government-ID strings …
status: z.enum(['PROBATIONARY', 'REGULAR', 'INACTIVE']),
positionId: z.coerce.number({ message: 'Position is required' }).int().positive('Position is required'),
basicSalary: z.coerce.number().min(0, 'Must be 0 or greater'),
// … five more coerced money fields …
});

type EmployeeFormValues = z.infer<typeof employeeFormSchema>;

Three decisions are packed in here. The type is inferred, never written — z.infer means the schema and the TypeScript type cannot drift, because there is only one of them. Error messages live next to their rules — the string a user reads sits in the same expression as the check that triggers it. And z.coerce.number() absorbs the DOM — HTML inputs and selects emit strings, even <Input type="number">, so the schema converts at the validation boundary instead of the submit handler. That is why onSubmit opens with a plain assignment, no field-by-field mapping:

const payload: EmployeeRequest = values;

The compact drawer that ../frontend/forms.md walks (DepartmentFormDrawer) makes the opposite choice — string ids in the form, Number(...) in onSubmit. Both are house style; the form shape and the DTO shape are allowed to differ, and coercion is just this file's way of making them not.

One sly detail: DEFAULT_VALUES sets positionId: 0, which fails .positive('Position is required') on purpose. An untouched position select is not a valid form, and the schema — not an extra "did they pick one" check — is what says so.

The wiring underneath is two lines of library:

} = useForm<EmployeeFormValues>({
resolver: zodResolver(employeeFormSchema),
defaultValues: DEFAULT_VALUES,
});

Reseeded on every open​

defaultValues applies once, at mount — and this drawer mounts once with the page, then serves every create and edit for the rest of the session. So values are re-seeded each time it opens:

useEffect(() => {
if (!open) return;

if (employee) {
reset({
firstName: employee.firstName,
// … every field, with '' standing in for nullable ones …
positionId: employee.position.id,
});
} else {
reset(DEFAULT_VALUES);
}
}, [open, employee, reset]);

Skip this effect and the bug writes itself: edit Yuki, close, click Add Employee — and Yuki's data greets you in the empty form. The ?? '' translations in the full reset exist because the DTO says middleName: string | null and a controlled input refuses null.

Fields that show their errors, a form that hides the browser's​

Every field is the same four-line sandwich:

<Field.Root invalid={Boolean(errors.firstName)} required>
<Field.Label>First Name <LabelTip tip="Employee's legal first (given) name as it appears on their ID" /></Field.Label>
<Input {...register('firstName')} />
<Field.ErrorText>{errors.firstName?.message}</Field.ErrorText>
</Field.Root>

register('firstName') spreads RHF's name/onChange/ref onto the input; errors.firstName?.message is the string from the schema. Twenty-two fields, one shape — which is what makes the 350-line file skimmable. The <form> element carries the other half of the error story, and its comment is the primary source:

{/* noValidate: let react-hook-form + zod render the styled field errors
instead of the browser's native required-field bubbles. */}
<form id="employee-form" noValidate onSubmit={handleSubmit(onSubmit)}>

Without noValidate, the browser's own required-field bubble fires first and the styled Field.ErrorText never gets a turn. One source of truth for validation means silencing the other one.

Now find the submit button. It is not inside the <form> at all:

<Drawer.Footer>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" form="employee-form" colorPalette="brand" loading={isSaving}>
{isEditMode ? 'Save Changes' : 'Create Employee'}
</Button>
</Drawer.Footer>

The form="employee-form" attribute links it to the form by id across the DOM. That keeps the footer sticky at the drawer's bottom edge — outside the scrolling Drawer.Body — while Enter-to-submit and handleSubmit still work natively. loading={isSaving} reads createMutation.isPending || updateMutation.isPending, so the button spins and locks during the round trip.

The submit path​

if (employee) {
updateMutation.mutate({ id: employee.employeeNumber, payload }, {
onSuccess: () => {
toaster.create({ title: 'Employee updated', type: 'success' });
onSuccess();
onClose();
},
onError: (error) => {
toaster.create({ title: getApiErrorMessage(error, 'Failed to update employee'), type: 'error' });
},
});
} else {
createMutation.mutate(payload, { /* … same shape, 'created' wording … */ });
}

Notice the branch is if (employee), not if (isEditMode). The update call needs employee.employeeNumber, so the fetched record is the condition — edit mode with the fetch still in flight cannot fire an update against a record it does not hold. Success does three things in order: toast, tell the page, close. Failure does one thing and leaves the drawer open with the user's input intact, because a rejected form you can fix beats a rejected form you must retype.

Predict: the user edits an employee, types an SSS number that already belongs to someone else, and clicks Save Changes. Zod passes — min(1) is satisfied — and the backend answers 400. What exact text lands in the toast, and which file decided it? Write your answer down before reading on.

When the server says no​

The answer is nine lines, quoted whole from api/errors.ts:

export const getApiErrorMessage = (error: unknown, fallback: string): string => {
if (axios.isAxiosError<ApiErrorResponse>(error) && error.response?.data.message) {
return error.response.data.message;
}
return fallback;
};

The server's own sentence wins. If the response body carries the backend contract's message — "SSS number already registered to employee 10012", or whatever the domain layer said — that is the toast, verbatim. The 'Failed to update employee' fallback only surfaces when there is no API answer to quote: a network drop, a non-Axios throw. So the string the user reads was written in Java, and the frontend's only job was not to lose it.

Why one toast instead of mapping server errors back onto fields, the way Zod errors land? The contract itself answers, in the comment on its optional field:

export interface ApiErrorResponse {
message: string;
status: number;
time: string;
/**
* Which request field the caller should fix, when the server knows. Absent on most responses —
* a form-level message is the right answer when no single input is at fault. Signup's 409 uses
* it so "that address is taken" lands under the address box instead of in a banner.
*/
field?: string;
}

The division of labor is deliberate: Zod owns field-level problems before the request leaves; the server owns domain-level problems, and those usually have no single guilty input. A duplicate-SSS conflict involves a whole other employee — pinning it under one box would be false precision.

Success is three hand-offs​

Trace what happens after a create lands, because three different owners each do one job. First, the mutation hook invalidates React Query:

export const useCreateEmployee = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: EmployeeRequest) => createEmployee(payload),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: EMPLOYEES_QUERY_KEY });
},
});
};

Every ['employees', …] query — the detail drawer's cached record, the all-employees list — refetches on next use, and every caller of this hook gets that for free. But the AG Grid rows are not React Query cache (lesson 08 owns that datasource), so the page wires the second hand-off itself:

<EmployeeFormDrawer
open={formOpen}
employeeId={editingId}
onClose={() => { setFormOpen(false); }}
onSuccess={refreshEverything}
/>

refreshEverything is lesson 12's all-tabs-and-counts refresh. The drawer never imports a grid API or a query key for it — it does not know it lives above a grid. The rule: mutations own cache invalidation, pages own grid refresh, and the drawer owns neither; it emits onSuccess and lets its host decide what "fresh" means.

What generalizes​

Strip the twenty-two fields away and the skeleton is the house style. Create and edit are one drawer, not two pages — the app never navigates away to edit a record. employeeId: number | null selects the mode; open exists only because null means create. One colocated Zod schema is the single source of validation, type included. Reset on open, submit from the footer via form=, toast the server's own message. And the callback contract is always the pair onClose/onSuccess.

For the full pattern reference — the compact DepartmentFormDrawer walkthrough, <Controller> for non-native widgets like rich text editors, and the one deliberate exception (Login runs on plain useState, since its errors are status-code driven, not field-level) — read ../frontend/forms.md. To build a whole new module around a drawer like this, the recipe is Build a module page.

Where this shows up in MotorPH​

Recap​

  • Control a form drawer with open plus employeeId: number | null — null means create, so it cannot also mean closed; view-only drawers drop open, form drawers cannot.
  • Colocate one Zod schema and infer the type from it — rules, messages, and the TypeScript type live in a single expression that cannot drift, with z.coerce.number() absorbing the DOM's strings.
  • reset(...) on every open, because defaultValues only runs at mount — the drawer mounts once and serves every create and edit after that.
  • noValidate on the form, submit button in the footer via form= — styled Field.ErrorText instead of browser bubbles, a sticky footer that still submits natively.
  • Toast the server's own message through getApiErrorMessage, then let each owner refresh its own state — the mutation invalidates React Query, the page refreshes the grid, and the drawer just reports onSuccess.

Next: 22 — The map and the gaps.