# Building a form

> Build a validated form with React Hook Form and Zod, wired to the typed API client and its errors.

## One stack, every form

Every form in the SPA uses the same three pieces: **Zod** for the schema, **React Hook Form**
for state, and the shadcn-style `Form` primitives in `shared/ui/form.tsx` for the markup. There
are no exceptions, not even single-field forms. The i18n wiring and the server-error mapping are
the same shape regardless of size, so the smallest form pays the same tax as the largest.

A form is built from four parts that live in a feature slice:

```
frontend/src/features/account/
  schemas.ts                       // Zod schema factories + inferred value types
  api.ts                           // typed apiFetch calls
  hooks.ts                         // TanStack Query mutation hooks
  components/ChangePasswordForm.tsx  // the form itself
```

## 1. Write the schema as a factory

Schemas live in `features/<slice>/schemas.ts`. Each one is a **factory function** that takes
i18next's `TFunction` and returns a `z.object`, so the validation messages resolve in the active
locale:

```ts

  return z
    .object({
      currentPassword: z.string().min(1, t('validation.current_password_required')),
      newPassword: z.string().min(8, t('validation.password_min')),
      confirmPassword: z.string(),
    })
    .refine((v) => v.newPassword === v.confirmPassword, {
      message: t('validation.passwords_must_match'),
      path: ['confirmPassword'],
    });
}

```

Why a factory and not a module-level `z.object` with static strings? A module-level schema freezes
its messages in whatever locale was active when the module first loaded. The factory rebuilds the
schema whenever `t` changes, which is what the language switcher needs. Export the inferred type
with `z.infer<ReturnType<typeof ...>>` so the form is typed from the schema, not duplicated by hand.

Cross-field rules (`confirmPassword` must match `newPassword`) go in `.refine()` with an explicit
`path`, so the error lands on the right field.

## 2. Add the typed call and a mutation hook

The form never calls `fetch` directly. The slice's `api.ts` wraps the typed client `apiFetch`,
which handles cookies, CSRF and error parsing for you (see [the typed API client](/docs/api-client)):

```ts

  changePassword: (body: ChangePasswordRequest) =>
    apiFetch<void>('/api/v1/users/me/change-password', { method: 'POST', body }),
};
```

Wrap that call in a TanStack Query mutation in `hooks.ts`:

```ts

  return useMutation({ mutationFn: accountApi.changePassword });
}
```

## 3. Wire React Hook Form to the schema

In the component, build the schema with `useMemo` keyed on `t`, hand it to `zodResolver`, and pass
sensible `defaultValues`:

```tsx

  const { t } = useTranslation();
  const change = useChangePassword();
  const schema = useMemo(() => changePasswordSchema(t), [t]);
  const form = useForm({
    resolver: zodResolver(schema),
    defaultValues: { currentPassword: '', newPassword: '', confirmPassword: '' },
  });
  // ...
}
```

## 4. Render with the Form primitives

The `Form` primitives in `@/shared/ui/form` connect each field to React Hook Form through context,
so you never read `form.formState.errors` by hand. `Form` is the provider, `FormField` binds a
`name` to a `Controller`, and `FormMessage` reads that field's error and renders it (or nothing):

```tsx

return (
  
    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
       (
          
            {t('account.change_password.current_password_label')}
            
              
            
            
          
        )}
      />
      
        {form.formState.isSubmitting
          ? t('account.change_password.submitting')
          : t('account.change_password.submit')}
      
    </form>
  
);
```

`FormControl` wires the rendered input (`{...field}`) to the field's id, `aria-invalid` and
`aria-describedby`, so accessibility is handled. Any input works inside it: `Input`,
`PasswordInput`, a `Select`, a `Checkbox`. Keep validation in Zod, not on the `<input>`: no
`required`, `pattern` or `minLength` attributes.

## 5. Submit and surface server errors

The submit handler calls the mutation with `mutateAsync` and catches failures. The typed client
throws an `AppError` (see [error handling](/docs/error-handling)), whose `applyToForm` maps the
server's per-field validation errors back onto the matching React Hook Form fields. It returns
`false` when there were no field errors to apply, which is your cue to fall back to a field message
or a toast:

```tsx

async function onSubmit(values: ChangePasswordValues) {
  try {
    await change.mutateAsync({
      currentPassword: values.currentPassword,
      newPassword: values.newPassword,
    });
    toast.success(t('account.change_password.success_toast'));
    form.reset();
  } catch (err) {
    if (err instanceof AppError && !err.applyToForm(form.setError)) {
      form.setError('currentPassword', {
        message: err.isUnauthenticated
          ? t('account.change_password.invalid_password')
          : (err.detail ?? t('account.change_password.error')),
      });
    } else if (!(err instanceof AppError)) {
      toast.error(t('account.change_password.error'));
    }
  }
}
```

`applyToForm` does the translation for you: it reads the server's validation codes, maps them
through the shared code map, resolves the message in the active locale, and converts `PascalCase`
field names to the camelCase your form uses. You just hand it `form.setError`.

## Things to avoid

- **No module-level schema constants.** They freeze their messages in one locale. Always a factory
  plus `useMemo`.
- **No native validation attributes** (`required`, `pattern`, `minLength`). Zod is the single source
  of truth.
- **No string concatenation for messages.** Translators cannot reorder fragments. Use interpolation
  (`t('validation.password_min', { min: 8 })`) or `` for embedded markup.
- **No reaching into `form.formState.errors` to display errors.** `FormMessage` reads them through
  context.
- **No raw `fetch`.** Go through the slice's `api.ts` and `apiFetch`.

## Checklist

- [ ] Schema is a factory taking `TFunction`, with the value type exported via `z.infer<ReturnType<...>>`.
- [ ] The component builds the schema with `useMemo` keyed on `t` and passes it to `zodResolver`.
- [ ] Every field is a `FormField` with a `FormMessage`; inputs sit inside `FormControl`.
- [ ] Submit calls a TanStack mutation through `mutateAsync`, not raw `fetch`.
- [ ] The catch block runs `AppError.applyToForm(form.setError)` and falls back to a toast.
- [ ] `pnpm typecheck` passes and toggling the language switcher re-renders visible errors.
