Files
budget/.plans/13-react-hook-form-zod.md
T
Spencer Twaddle 087fbdd176 Split into Budget.Core / Budget.Infrastructure / Budget.Api projects
Budget.Core: entities, DTOs, enums, FrequencyCalculator (no EF/ASP.NET deps)
Budget.Infrastructure: AppDbContext, migrations, BudgetAuthorizationService
Budget.Api: controllers, middleware, Program.cs — references both projects

EF and Npgsql packages moved to Infrastructure; Api retains only JwtBearer,
HealthChecks, and EF.Design (needed for dotnet ef CLI). Dockerfile updated
to copy all three project directories before publishing. Migration namespaces
updated from Budget.Api.Data.* to Budget.Infrastructure.Data.* and model
type strings updated to Budget.Core.Models.* in the snapshot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-02 16:30:31 -05:00

4.3 KiB

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:

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

  1. Create src/Budget.Client/src/schemas/index.ts with one schema per create/update request:
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:

const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm<CreateBudgetRequest>({
  resolver: zodResolver(createBudgetSchema),
});

const onSubmit = async (data: CreateBudgetRequest) => {
  await createBudget.mutateAsync(data);
  reset();
};

return (
  <form onSubmit={handleSubmit(onSubmit)}>
    <input {...register('name')} />
    {errors.name && <span>{errors.name.message}</span>}
    <button type="submit" disabled={isSubmitting}>Create</button>
  </form>
);
  1. BudgetsPage.tsx — wire createBudgetSchema into the budget name input.
  2. IncomePage.tsx — wire createIncomeSchema / updateIncomeSchema for the add and edit income forms.
  3. OutgoPage.tsx — wire createOutgoSchema / updateOutgoSchema for add and edit outgo forms (this is the most complex form).
  4. SettingsPage.tsx — wire createShareSchema for the invite form; updateShareSchema for the permission dropdown.
  5. SummaryPage.tsx — wire updateTaxRateSchema for the tax rate field.

Phase 4 — Disable submit during mutation

  1. Set disabled={isSubmitting || mutation.isPending} on submit buttons. This prevents double-submits while the network request is in flight.

Phase 5 — Build and verify

  1. npm run build — zero TypeScript errors.
  2. 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 <input type="number"> 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