> ## Documentation Index
> Fetch the complete documentation index at: https://ship.paralect.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Forms

> react-hook-form + Zod, wired to the typed oRPC client with useApiForm, useApiMutation and handleApiError.

Ship forms are [react-hook-form](https://react-hook-form.com/) + [Zod](https://zod.dev/), submitted through the typed oRPC client. Three pieces do the work: `useApiForm` (a form bound to a Zod schema), `useApiMutation` (the typed mutation), and `handleApiError` (server errors mapped back onto fields).

<Info>
  `useApiForm`, `useApiMutation` and `handleApiError` ship with the **Auth plugin** (`auth-starter`), which adds the full-stack web wiring on top of the base app.
</Info>

## useApiForm

`useApiForm` takes a **Zod schema** and returns a typed `react-hook-form` instance with the `zodResolver` already applied. The form's values are inferred from the schema — no separate `FormParams` type to maintain.

```tsx theme={null}
import { z } from 'zod';
import { useApiForm } from '@/hooks';

const schema = z.object({
  fullName: z.string().min(1, 'Full name is required').max(128),
});

const form = useApiForm(schema, { mode: 'onBlur' });
//    ^ UseFormReturn<{ fullName: string }>
```

Pass any `useForm` options (`mode`, `defaultValues`, …) as the second argument; only `resolver` is owned by the hook.

<Note>
  This repo uses **Zod 4**. Reach for `z.email()`, `z.url()`, `z.uuid()` rather than Zod 3's `z.string().email()`.
</Note>

## Submitting

Pair the form with `useApiMutation` for a matching endpoint. On success, seed or invalidate the cache; on error, hand the error to `handleApiError`:

```tsx theme={null}
import { useApiForm, useApiMutation, queryKey } from '@/hooks';
import { apiClient } from '@/services/api-client.service';
import { handleApiError } from '@/utils';
import queryClient from '@/query-client';

const form = useApiForm(schema, { defaultValues: { fullName: '' } });
const { mutate, isPending } = useApiMutation(apiClient.users.patchCurrent);

const onSubmit = form.handleSubmit((data) =>
  mutate(data, {
    onSuccess: (user) => queryClient.setQueryData(queryKey(apiClient.users.getCurrent), user),
    onError: (e) => handleApiError(e, form.setError),
  }),
);
```

## handleApiError

`handleApiError(e, setError?)` from `@/utils` reads the structured `errors` object off an oRPC error:

* Field errors (`{ fullName: 'Full name is required' }`) are pushed onto the form via `setError`, focusing the first one.
* A `global` error is surfaced as a [Sonner](https://sonner.emilkowal.ski/) toast.

Pass `form.setError` to route messages onto fields; omit it to only toast.

```tsx theme={null}
import { handleApiError } from '@/utils';

mutate(data, { onError: (e) => handleApiError(e, form.setError) });
```

## Complete example

A real route component — a TanStack Router route under `apps/web/src/routes/**`. No document `<head>` helpers, no page-scope wrappers; layout and auth are handled by the parent route (`_authenticated`). The form lives in `FormProvider` so child inputs can use `useFormContext`.

```tsx theme={null}
import { createFileRoute } from '@tanstack/react-router';
import { Loader2 } from 'lucide-react';
import { FormProvider } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';

import { queryKey, useApiForm, useApiMutation, useCurrentUser } from '@/hooks';
import { apiClient } from '@/services/api-client.service';
import { handleApiError } from '@/utils';
import queryClient from '@/query-client';

import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

const profileSchema = z.object({
  fullName: z.string().min(1, 'Full name is required').max(128),
});

function ProfileForm() {
  const { data: user } = useCurrentUser();

  const methods = useApiForm(profileSchema, {
    mode: 'onBlur',
    defaultValues: { fullName: user?.fullName ?? '' },
  });

  const { mutate, isPending } = useApiMutation(apiClient.users.patchCurrent);

  const onSubmit = methods.handleSubmit((data) =>
    mutate(data, {
      onSuccess: (updated) => {
        if (!updated) return;
        queryClient.setQueryData(queryKey(apiClient.users.getCurrent), updated);
        toast.success('Your profile has been updated.');
        methods.reset({ fullName: updated.fullName });
      },
      onError: (e) => handleApiError(e, methods.setError),
    }),
  );

  const { register, formState } = methods;

  return (
    <FormProvider {...methods}>
      <form onSubmit={onSubmit} className="flex flex-col gap-4">
        <div className="flex flex-col gap-2">
          <Label htmlFor="fullName">Full Name</Label>
          <Input
            {...register('fullName')}
            id="fullName"
            placeholder="Enter full name"
            className={formState.errors.fullName ? 'border-destructive' : ''}
          />
          {formState.errors.fullName && (
            <p className="text-sm text-destructive">{formState.errors.fullName.message}</p>
          )}
        </div>

        <Button type="submit" disabled={!formState.isDirty || isPending}>
          {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
          Save Changes
        </Button>
      </form>
    </FormProvider>
  );
}

export const Route = createFileRoute('/_authenticated/app/profile')({
  component: ProfileForm,
});
```

The mutation input is typed from the endpoint's `.input()` schema, the response is typed from its `.output()`, and validation messages your handler returns under `errors` land back on the right fields — one schema, checked on both sides. See [Calling the API](/docs/web/calling-api) for the client and query keys.
