partial (source-backed; frontend/browser/type-check evidence remains open)

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 — FormMessage from shadcn/form wired
    • Given a form with an email field, when I type a malformed email, I see a format error — LoginPage loginSchema validates email format
  • 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
  • As a developer, I want a single useZodForm() hook that wraps useForm + zodResolver, so that every form in the codebase uses the same validation pipeline.

    • Given a new form component, importing useZodForm gives me typed fields, errors, and submit handling — frontend/src/hooks/useZodForm.ts shipped; 4 pages migrated (Login, Register, PasswordReset, Settings)

3. Scope Boundaries

  • In: Installation (react-hook-form, @hookform/resolvers, zod), shared useZodForm() hook, migration of existing forms one-by-one, Zod schemas co-located with forms or in shared src/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 over useForm<SchemaType>({ resolver: zodResolver(schema) })
  • Schemas: Co-located with each form owner (e.g. loginSchema in LoginForm.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> from frontend/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 through react-i18next

7. Test Plan

#CaseTypeWhere
1useZodForm validates required fieldsfrontendfrontend/src/test/useZodForm.test.ts
2useZodForm shows inline errors on blurfrontendfrontend/src/test/useZodForm.test.ts
3Migration: each migrated form still submits correctlye2eExisting describe blocks
4Migration: migrated form shows validation errorse2eExisting 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 → FormMessage shows the first Zod error message
  • Server error (e.g. 400 with field errors): onSubmit catch → set field errors via setError() + 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 inline
  • RegisterPage: registerSchema — required name + email + password; 6 tests in RegisterPage.test.tsx
  • PasswordResetPage: passwordResetSchema — email only
  • SettingsPage: orgSettingsSchema — org name min-2/max-100, required timezone + brief_time; 4 tests in SettingsPage.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 convention
  • docs/ADR/000-index.md — no ADR needed for a tooling addition

11. Implementation Audit

#ClaimStatusEvidence
1useZodForm hook wraps useForm with zodResolver✅ source matchfrontend/src/hooks/useZodForm.ts
2shadcn Form/FormField/FormLabel/FormControl/FormMessage wired✅ matchfrontend/src/components/ui/form.tsx
3Login form migrated from useState to useZodForm✅ matchLoginForm.tsx
4PasswordResetPage migrated from useState to useZodForm✅ matchPasswordResetPage.tsx
5RegisterPage migrated — inline validation for name/email/password✅ matchRegisterPage.tsx
6SettingsPage org settings form migrated — name min-2/max-100, required timezone/brief_time✅ matchSettingsPage.tsx
7useZodForm unit tests✅ source matchuseZodForm.test.ts; Vitest execution remains open
8RegisterPage validation tests✅ source matchRegisterPage.test.tsx; Vitest execution remains open
9SettingsPage org name validation tests✅ source matchSettingsPage.test.tsx; Vitest execution remains open
10confirm_password_label i18n key propagated to all 60+ locales✅ matchPropagated 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 to src/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 useForm callers remain outside it; Vitest, browser, and TypeScript execution remain explicit gaps.