ADR 0003: Admin write forms use shadcn <Form> + react-hook-form + zod
- Status: accepted
- Date: 2026-08-16
- Deciders: M27 admin dashboard milestone; first write slice #487 (M27-10)
- Design of record: Admin dashboard (§"Forward-looking: forms standard for write slices")
Context
M27-10 is the first slice to put input forms into the admin console (apps/admin): the
users.update_name rename and the grant-management role picker, with more write forms coming
in #488/#489. A form standard has to be picked once, before form code proliferates.
The global engineering default for this codebase prefers TanStack Form. The admin app,
however, is built on ra-core (the react-admin engine, chosen in M27-9), and ra-core's own
form layer is react-hook-form — RHF ships in the app's dependency graph and runs inside the
kit regardless of what we pick. The recorded design of record already anticipated this:
"admin forms use the project standard: shadcn Form + react-hook-form + zod".
Decision
Every write form in the admin console uses shadcn/ui <Form> (now in @bloom/ui) over
react-hook-form, validated by a zod schema via @hookform/resolvers/zod.
The zod schemas live in apps/admin/src/lib/admin/actions.ts and are the client mirror of
the server's allowed-fields whitelist (apps/api/.../routes/admin_actions.py body models,
which set extra="forbid"). One schema per body model, field for field: a field the verb
rejects must not exist in the schema, so the form cannot even offer it. actions.test.ts
pins the mirror (lengths, enums, .strict(), the bulk cap).
Deviation from the global default — justification
This deviates from the TanStack Form default deliberately:
- ra-core alignment. react-admin/ra-core runs react-hook-form internally. Adopting TanStack Form would put two form runtimes in one small SPA — double bundle, two validation idioms, and impedance whenever a form needs ra-core context.
- shadcn
<Form>is RHF-native. The shadcn form family (FormField/FormItem/FormMessage) is a thin RHF wrapper; it composes with the@bloom/uiprimitives already in use with no adapter. - zod is the schema-mirror tool.
.strict()objects give the "no un-whitelisted field" property the server enforces withextra="forbid", keeping client and server rejections identical.
TanStack Query/Router/Table remain the defaults elsewhere; this ADR covers forms in
apps/admin only. The user dashboard (apps/web) has no form standard change.
Consequences
@bloom/uigainsform.tsx(+label.tsx) withreact-hook-formas a package dependency; consumers that render no forms tree-shake it away.- Write slices #488/#489 add forms by: server body model → mirrored zod schema in
actions.ts→<Form>+zodResolverdialog, asuser-actions.tsxdoes. - If ra-core is ever replaced, this decision is re-opened (its premise is ra-core alignment).