# Forms with react-hook-form

> One zod schema checks a react-hook-form form in the browser and the procedure's input on the server, and matchError() puts each server error on its field.

Experimental: Kitchen sink might change or go away in any release.

The demo edits a profile. Its form is checked twice by one schema, in the browser before it's sent and by the procedure when it arrives, and whatever the server still rejects lands on the field it's about. Its pages load their data in the route's loader, so they render with it.

The web page runs this demo, editable, beside the text: [Edit a profile](https://github.com/trpc/trpc/blob/v12/next/www/samples/kitchen-sink-forms/src).

Things to try:

- Save a one-letter username. The browser's check stops it before a request is sent.
- Turn off **Check in the browser too** and save it again. The request goes out, and the server's `BAD_REQUEST` puts the same message on the same field.
- Save `admin` as the username. Only the server knows it's taken, and its `CONFLICT` lands on the username field.
- Press **Fail next request**, then save: the call fails with `NETWORK_ERROR`, shown above the button.
- Edit the code: change a message in `schema.ts`, or the taken usernames in `server.ts`, and save the form again.

## One schema

The schema lives in a file both sides import. The form holds the schema's input type, before `.trim()` runs:

```ts title="schema.ts"
/**
 * The profile form's fields. The form checks them in the browser and the
 * procedure's `.input()` checks them again on the server: one schema, so
 * the two can't disagree.
 */
export const profileSchema = z.object({
  name: z
    .string()
    .trim()
    .min(1, 'Enter your name')
    .max(40, 'At most 40 characters'),
  username: z
    .string()
    .regex(/^[a-z0-9_]{3,20}$/, '3 to 20 lowercase letters, digits or _'),
  email: z.email('Enter an email address'),
  bio: z.string().max(120, 'At most 120 characters'),
});

/** What the form holds: the schema's input, before `.trim()`. */
export type ProfileInput = z.input<typeof profileSchema>;
```

The procedure takes the same schema as its `.input()`, so a request the browser didn't check is checked anyway. A taken username is something only the server can know: the resolver returns it as an `error()` with the field it's about in `data`, which puts it in the client's error type ([Errors](https://v12.trpc.io/v12/docs/server/errors.md)):

```ts title="server.ts"
export const appRouter = {
  profile: {
    get: t.procedure.query(() => db.profile),
    changes: t.procedure.query(() => db.changes.slice(0, 5)),
    update: t.procedure
      // The same schema as the form's: a request that skips the browser's
      // check gets a `BAD_REQUEST` with the schema's issues.
      .input(profileSchema)
      .mutation((opts) => {
        const next = opts.input;
        if (
          next.username !== db.profile.username &&
          db.taken.has(next.username)
        ) {
          // Returned, not thrown: the client's error type includes it.
          return error({
            code: 'CONFLICT',
            message: `@${next.username} is taken`,
            data: { field: 'username' as const },
          });
        }
        const at = new Date().toISOString();
        for (const field of ['name', 'username', 'email', 'bio'] as const) {
          if (next[field] !== db.profile[field]) {
            db.changes.unshift({
              at,
              field,
              from: db.profile[field],
              to: next[field],
            });
          }
        }
        db.profile = { ...next, updatedAt: at };
        return db.profile;
      }),
  },
};

export type AppRouter = typeof appRouter;
```

## Server errors on their fields

`standardSchemaResolver` runs the schema in the browser. On submit, the form calls the procedure with `safe()`, and `matchError()` takes a handler for each way the call can fail, all required and each typed from the server ([Handling every error](https://v12.trpc.io/v12/docs/client/errors.md#handling-every-error)):

```tsx title="edit-profile.tsx"
const resolver = standardSchemaResolver(profileSchema);
const form = useForm<ProfileInput>({
  resolver: (values, context, options) =>
    checkInBrowser
      ? resolver(values, context, options)
      : { values, errors: {} },
  defaultValues: {
    name: profile.name,
    username: profile.username,
    email: profile.email,
    bio: profile.bio,
  },
});

const save = form.handleSubmit(async (values) => {
  const [saved, err] = await safe(ctx.client.profile.update.mutate(values));
  if (err) {
    // One handler per way the call can fail, inferred from the server.
    matchError(err, {
      // The schema's issues, with paths typed from its input.
      BAD_REQUEST: (e) => {
        for (const issue of e.data.issues) {
          form.setError(issue.path[0] ?? 'root.server', {
            message: issue.message,
          });
        }
      },
      // The resolver's returned `error()`, with its `data`.
      CONFLICT: (e) => form.setError(e.data.field, { message: e.message }),
      NETWORK_ERROR: () =>
        form.setError('root.server', {
          message: "Couldn't reach the server. Try again.",
        }),
      BAD_RESPONSE: () =>
        form.setError('root.server', { message: 'Try again later.' }),
      unexpected: (e) => form.setError('root.server', { message: e.message }),
    });
    return;
  }
  ctx.queryClient.setQueryData(ctx.trpc.profile.get.queryKey(), saved);
  await ctx.queryClient.invalidateQueries(
    ctx.trpc.profile.changes.queryFilter(),
  );
  await navigate({ to: '/' });
});
```

- `BAD_REQUEST` is the input's: its `data.issues` are the schema's issues, and each issue's `path` is typed from the input, so `issue.path[0]` is a field name `setError()` takes without a cast ([Input issues](https://v12.trpc.io/v12/docs/server/procedures.md#input-issues)).
- `CONFLICT` is the resolver's own, with `data.field` typed as `'username'`.
- `NETWORK_ERROR` and `BAD_RESPONSE` are `httpLink`'s: every call through it can fail with them.
- `unexpected` takes the rest, such as a crash on the server.

Adding an `error()` to the resolver makes this `matchError()` a type error until it handles the new code, and removing one makes its handler left over a type error.

## Loading in the loader

A route's loader starts the page's queries before it renders, and its component reads them. The demo has a small helper for that: `prefetch()` starts a query without waiting, `ensure()` waits for it (or takes it from the cache), and `useLoaderQuery()` reads what the loader started, with no second list of query options:

```ts title="loader-query.ts"
/**
 * A query a route's loader started, for the route's components to read.
 * The loader decides when each request starts and whether the page waits
 * for it; components read what it started without naming the query again.
 */
export interface LoaderQuery<TData, TError> {
  readonly options: QueryOptionsOut<TData, TError, TData>;
}

/**
 * Starts a query and doesn't wait: the page renders, and the component
 * reading it suspends until it arrives.
 */
export function prefetch<TData, TError>(
  queryClient: QueryClient,
  options: QueryOptionsOut<TData, TError, TData>,
): LoaderQuery<TData, TError> {
  // Its error, if any, is the reading component's to throw.
  queryClient.query(options).catch(noop);
  return { options };
}

/**
 * Starts a query, or reads it from the cache however old, and the page
 * waits for it. Stale data refetches once a component reads it.
 */
export async function ensure<TData, TError>(
  queryClient: QueryClient,
  options: QueryOptionsOut<TData, TError, TData>,
): Promise<LoaderQuery<TData, TError>> {
  await queryClient.query({ ...options, staleTime: 'static' });
  return { options };
}

/**
 * The data of a query the loader started. It stays subscribed, so an
 * invalidation or a `setQueryData` re-renders with the new data.
 */
export function useLoaderQuery<TData, TError>(
  query: LoaderQuery<TData, TError>,
): TData {
  return useSuspenseQuery(query.options).data;
}
```

The profile page waits for the profile, and lets the list of recent changes arrive while it shows. With the network on **Slow**, the page appears with the profile and a placeholder where the changes come in:

```tsx title="routes.tsx"
export const profileRoute = createRoute({
  getParentRoute: () => rootRoute,
  path: '/',
  loader: async (opts) => {
    const ctx = opts.context;
    // Starts now and isn't waited for: <Changes> suspends until it arrives.
    const changes = prefetch(
      ctx.queryClient,
      ctx.trpc.profile.changes.queryOptions(),
    );
    // The page waits for the profile, so it renders with it.
    const profile = await ensure(
      ctx.queryClient,
      ctx.trpc.profile.get.queryOptions(),
    );
    return { changes, profile };
  },
  component: ProfilePage,
});

function ProfilePage(): ReactNode {
  const data = profileRoute.useLoaderData();
  const profile = useLoaderQuery(data.profile);
  return (
    <div className="grid gap-5">
      <div className="flex items-center gap-3">
        <span className="grid size-12 place-items-center rounded-full bg-brand/15 text-lg font-semibold text-brand">
          {profile.name.charAt(0)}
        </span>
        <div className="grid">
          <span className="font-semibold">{profile.name}</span>
          <span className="text-sm text-muted-foreground">
            @{profile.username} · {profile.email}
          </span>
        </div>
      </div>
      <p className="text-sm">{profile.bio}</p>
      <Suspense fallback={<Skeleton className="h-16 w-full" />}>
        <Changes />
      </Suspense>
    </div>
  );
}
```

The edit page's loader waits for the profile too, so the form's default values are there when it first renders. After a save, the form puts the saved profile in the cache with `setQueryData()`, invalidates the changes, and navigates to the profile page, which shows the new profile without a request and refetches the changes.
