Frontend
Building a form
Build a validated form with React Hook Form and Zod, wired to the typed API client and its errors.
On this page
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:
import type { TFunction } from 'i18next';
import { z } from 'zod';
export function changePasswordSchema(t: TFunction) {
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'],
});
}
export type ChangePasswordValues = z.infer<ReturnType<typeof changePasswordSchema>>;
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):
import { apiFetch } from '@/shared/api/client';
import type { ChangePasswordRequest } from './types';
export const accountApi = {
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:
import { useMutation } from '@tanstack/react-query';
import { accountApi } from './api';
export function useChangePassword() {
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:
import { useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslation } from 'react-i18next';
import { changePasswordSchema, type ChangePasswordValues } from '../schemas';
import { useChangePassword } from '../hooks';
export function ChangePasswordForm() {
const { t } = useTranslation();
const change = useChangePassword();
const schema = useMemo(() => changePasswordSchema(t), [t]);
const form = useForm<ChangePasswordValues>({
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):
import { Button } from '@/shared/ui/button';
import { PasswordInput } from '@/shared/ui/password-input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/shared/ui/form';
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="currentPassword"
render={({ field }) => (
<FormItem>
<FormLabel>{t('account.change_password.current_password_label')}</FormLabel>
<FormControl>
<PasswordInput autoComplete="current-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting
? t('account.change_password.submitting')
: t('account.change_password.submit')}
</Button>
</form>
</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), 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:
import { toast } from 'sonner';
import { AppError } from '@/shared/api/errors';
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<Trans>for embedded markup. - No reaching into
form.formState.errorsto display errors.FormMessagereads them through context. - No raw
fetch. Go through the slice’sapi.tsandapiFetch.
Checklist
- Schema is a factory taking
TFunction, with the value type exported viaz.infer<ReturnType<...>>. - The component builds the schema with
useMemokeyed ontand passes it tozodResolver. - Every field is a
FormFieldwith aFormMessage; inputs sit insideFormControl. - Submit calls a TanStack mutation through
mutateAsync, not rawfetch. - The catch block runs
AppError.applyToForm(form.setError)and falls back to a toast. -
pnpm typecheckpasses and toggling the language switcher re-renders visible errors.