# Plan: Add react-hook-form + zod for Form Validation ## Goal Replace ad-hoc form state (`useState` per field, manual validation) with `react-hook-form` and `zod` schemas, matching the stwaddle stack convention. Validation errors render inline next to fields. **Prerequisite:** Plan #12 (TanStack React Query) must be complete. Mutation hooks from that plan are used as the submit handlers here — `useForm` `handleSubmit` calls `mutation.mutateAsync()`. ## Current state Forms are managed ad-hoc: ```tsx const [newName, setNewName] = useState(''); const handleCreate = async () => { if (!newName.trim()) return; await createMutation.mutateAsync({ name: newName.trim() }); }; ``` No schema validation, no field-level error messages, no loading/disabled states during submit. ## Steps ### Phase 1 — Install 1. `npm install react-hook-form zod @hookform/resolvers` in `src/Budget.Client/`. ### Phase 2 — Create zod schemas 2. Create `src/Budget.Client/src/schemas/index.ts` with one schema per create/update request: ```ts export const createBudgetSchema = z.object({ name: z.string().min(1, 'Name is required').max(200), }); export const createIncomeSchema = z.object({ name: z.string().min(1, 'Name is required').max(200), frequency: z.nativeEnum(Frequency), amount: z.number({ invalid_type_error: 'Amount is required' }).positive('Must be positive'), }); export const createOutgoSchema = z.object({ name: z.string().min(1).max(200), category: z.string().max(100).optional(), type: z.nativeEnum(OutgoType), frequency: z.nativeEnum(Frequency), amount: z.number().positive(), paymentSource: z.string().max(100).optional(), notes: z.string().max(1000).optional(), }); export const createShareSchema = z.object({ email: z.string().email('Must be a valid email'), permission: z.nativeEnum(SharePermission), }); export const updateTaxRateSchema = z.object({ effectiveTaxRate: z.number().min(0).max(0.99), }); ``` ### Phase 3 — Update forms in pages For each form, the pattern is: ```tsx const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm({ resolver: zodResolver(createBudgetSchema), }); const onSubmit = async (data: CreateBudgetRequest) => { await createBudget.mutateAsync(data); reset(); }; return (
{errors.name && {errors.name.message}}
); ``` 3. **`BudgetsPage.tsx`** — wire `createBudgetSchema` into the budget name input. 4. **`IncomePage.tsx`** — wire `createIncomeSchema` / `updateIncomeSchema` for the add and edit income forms. 5. **`OutgoPage.tsx`** — wire `createOutgoSchema` / `updateOutgoSchema` for add and edit outgo forms (this is the most complex form). 6. **`SettingsPage.tsx`** — wire `createShareSchema` for the invite form; `updateShareSchema` for the permission dropdown. 7. **`SummaryPage.tsx`** — wire `updateTaxRateSchema` for the tax rate field. ### Phase 4 — Disable submit during mutation 8. Set `disabled={isSubmitting || mutation.isPending}` on submit buttons. This prevents double-submits while the network request is in flight. ### Phase 5 — Build and verify 9. `npm run build` — zero TypeScript errors. 10. Manually verify that submitting an empty name shows an inline error without hitting the API. ## Key decisions - Schemas live in a single `schemas/index.ts` for now — split into per-domain files if they grow large. - `amount` fields: HTML `` returns a string; use `z.coerce.number()` or `valueAsNumber: true` in `register()` to get a proper number before Zod validates. - Update schemas (for edit forms) reuse the same shape as create schemas where the fields are identical — they are not duplicated. - No server-side error mapping in this plan: if the API returns 422/400 with `{ error: "..." }`, the global MutationCache toast (from plan #12) handles it. Field-level server errors can be mapped via `setError` in a future pass if needed. ## Files affected - `package.json` - New: `src/Budget.Client/src/schemas/index.ts` - `src/Budget.Client/src/pages/BudgetsPage.tsx` - `src/Budget.Client/src/pages/IncomePage.tsx` - `src/Budget.Client/src/pages/OutgoPage.tsx` - `src/Budget.Client/src/pages/SettingsPage.tsx` - `src/Budget.Client/src/pages/SummaryPage.tsx`