Spec: F63 — React Hook Form + Zod (Form Handling)
Status: partial (source-backed; frontend/browser/type-check evidence remains open)
1. Problem & CEO Job
Decide fast (job 2) — forms are Lumi’s primary input surface. A shared React Hook Form + Zod pipeline standardises typed validation and inline error rendering while preserving the existing page-specific schemas and server submission behavior.
2. User Stories & Acceptance Criteria
-
As a CEO, I want clear, immediate validation feedback on every form field, so that I know why my input is wrong without submitting.
- Given a form with a required field, when I tab past it empty, I see an inline error message —
FormMessagefrom shadcn/form wired - Given a form with an email field, when I type a malformed email, I see a format error — LoginPage
loginSchemavalidates email format
- Given a form with a required field, when I tab past it empty, I see an inline error message —
-
As a developer, I want a typed schema that generates both runtime validation and TypeScript types, so that validation logic and type definitions never get out of sync.
- Given a Zod schema for a form, the inferred TypeScript type matches the form data shape —
z.infer<typeof schema>pattern used in all migrated forms - Given a form schema change, all usages that break produce a compile error — 0 TS errors in
tsc --noEmit
- Given a Zod schema for a form, the inferred TypeScript type matches the form data shape —
-
As a developer, I want a single
useZodForm()hook that wrapsuseForm+zodResolver, so that every form in the codebase uses the same validation pipeline.- Given a new form component, importing
useZodFormgives me typed fields, errors, and submit handling —frontend/src/hooks/useZodForm.tsshipped; 4 pages migrated (Login, Register, PasswordReset, Settings)
- Given a new form component, importing
3. Scope Boundaries
- In: Installation (
react-hook-form,@hookform/resolvers,zod), shareduseZodForm()hook, migration of existing forms one-by-one, Zod schemas co-located with forms or in sharedsrc/schemas/for reused shapes - Out: Complex multi-step wizards beyond what we already have (onboarding can use the same hook), dynamically generated forms from server config
4. Data Model
n/a — form schemas are frontend-only, generated from or validated against existing API contracts.
5. API Contract
n/a — forms submit to existing endpoints.
6. UI / UX
- Shared hook:
frontend/src/hooks/useZodForm.ts— thin wrapper overuseForm<SchemaType>({ resolver: zodResolver(schema) }) - Schemas: Co-located with each form owner (e.g.
loginSchemainLoginForm.tsx) —frontend/src/schemas/directory does not exist yet; schemas move there when reused across 2+ forms (decision in §12) - Error display: Use shadcn’s
<FormField>,<FormLabel>,<FormMessage>fromfrontend/src/components/ui/form.tsx(already exists) - States: loading (submit button disabled + spinner), error (inline field errors + optional toast for server errors), success (toast + redirect or reset)
- i18n: Error messages from Zod schemas use
t()within the resolver or pass English keys throughreact-i18next
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | useZodForm validates required fields | frontend | frontend/src/test/useZodForm.test.ts |
| 2 | useZodForm shows inline errors on blur | frontend | frontend/src/test/useZodForm.test.ts |
| 3 | Migration: each migrated form still submits correctly | e2e | Existing describe blocks |
| 4 | Migration: migrated form shows validation errors | e2e | Existing describe blocks |
8. Current Behavior
The source-backed convention is shipped: useZodForm wraps useForm with zodResolver, FormField/FormMessage are provided by the shadcn form primitives, and the current form code survey finds no direct useForm callers outside the shared hook. Twenty page/component sources use useZodForm. Vitest, browser, and TypeScript execution remain explicit proof gaps in a fresh checkout without frontend/node_modules.
9. Error States
- Validation error: Zod schema rejects →
FormMessageshows the first Zod error message - Server error (e.g. 400 with field errors):
onSubmitcatch → set field errors viasetError()+ toast for non-field errors - Network error: toast “Lỗi kết nối. Vui lòng thử lại.”
Per-form specifics (as shipped):
LoginPage:loginSchema— required email + min-8 password; field errors inlineRegisterPage:registerSchema— required name + email + password; 6 tests inRegisterPage.test.tsxPasswordResetPage:passwordResetSchema— email onlySettingsPage:orgSettingsSchema— org name min-2/max-100, required timezone + brief_time; 4 tests inSettingsPage.test.tsx
Zod version: 4.4.3 (import type { core } from 'zod' pattern confirms Zod 4). Resolver: @hookform/resolvers@5.4.0. Hook: react-hook-form@7.77.0.
10. Diagrams & Docs Touched
frontend/AGENTS.md— add form handling conventiondocs/ADR/000-index.md— no ADR needed for a tooling addition
11. Implementation Audit
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | useZodForm hook wraps useForm with zodResolver | ✅ source match | frontend/src/hooks/useZodForm.ts |
| 2 | shadcn Form/FormField/FormLabel/FormControl/FormMessage wired | ✅ match | frontend/src/components/ui/form.tsx |
| 3 | Login form migrated from useState to useZodForm | ✅ match | LoginForm.tsx |
| 4 | PasswordResetPage migrated from useState to useZodForm | ✅ match | PasswordResetPage.tsx |
| 5 | RegisterPage migrated — inline validation for name/email/password | ✅ match | RegisterPage.tsx |
| 6 | SettingsPage org settings form migrated — name min-2/max-100, required timezone/brief_time | ✅ match | SettingsPage.tsx |
| 7 | useZodForm unit tests | ✅ source match | useZodForm.test.ts; Vitest execution remains open |
| 8 | RegisterPage validation tests | ✅ source match | RegisterPage.test.tsx; Vitest execution remains open |
| 9 | SettingsPage org name validation tests | ✅ source match | SettingsPage.test.tsx; Vitest execution remains open |
| 10 | confirm_password_label i18n key propagated to all 60+ locales | ✅ match | Propagated to 51 missing locales (2026-06-14) |
12. Open Questions / Decisions
- Should Zod schemas live in
src/schemas/(shared) or co-located with forms? Decision: co-located by default; move tosrc/schemas/when a schema is reused across 2+ forms.
13. Bug / Change Log
- 2026-06-14: SP-C audit. Updated §2 ACs to reflect shipped state (4 forms migrated), corrected §6 schema directory (does not exist — co-location is the actual pattern), expanded §9 with per-form specifics and Zod version. Status updated:
agreed→shipped. - 2026-07-16: Contract Graph audit reconciled the stale hand-rolled-form narrative with current source. The shared hook is present and no direct
useFormcallers remain outside it; Vitest, browser, and TypeScript execution remain explicit gaps.