Skip to main content

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/ui primitives already in use with no adapter.
  • zod is the schema-mirror tool. .strict() objects give the "no un-whitelisted field" property the server enforces with extra="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/ui gains form.tsx (+ label.tsx) with react-hook-form as 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> + zodResolver dialog, as user-actions.tsx does.
  • If ra-core is ever replaced, this decision is re-opened (its premise is ra-core alignment).