Skip to content
Slicekit

Frontend

Building a form

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

View .md
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.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.