Формы — место, где frontend встречается с пользователем и с backend одновременно: ввод надо собрать, проверить и отправить в той форме, которую ждёт сервер. Наивный подход (каждое поле в useState, валидация руками) быстро превращается в лапшу. Связка react-hook-form + zod решает обе задачи — управление полями и валидацию — и заодно даёт согласованность с контрактом API.

react-hook-form: управление полями

react-hook-form работает с полями как с неконтролируемыми, регистрируя их через register. За счёт этого ввод в одно поле не перерисовывает всю форму — на больших формах это заметно.

import { useForm } from "react-hook-form";

type LoginForm = { email: string; password: string };

function Login() {
  const { register, handleSubmit, formState: { errors } } = useForm<LoginForm>();

  const onSubmit = (data: LoginForm) => {
    // data типизирована, поля собраны
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("email")} />
      <input type="password" {...register("password")} />
      <button type="submit">Войти</button>
    </form>
  );
}

handleSubmit собирает значения и вызывает колбэк только если форма валидна; formState.errors несёт ошибки полей.

zod: схема валидации

Правила валидации описывают схемой zod — декларативно, в одном месте. zodResolver подключает её к форме.

import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";

const createProductSchema = z.object({
  name: z.string().min(1, "Укажите название").max(200),
  price: z.number().int().positive("Цена должна быть больше нуля"),
});

type CreateProductForm = z.infer<typeof createProductSchema>;

function CreateProduct() {
  const { register, handleSubmit, formState: { errors } } =
    useForm<CreateProductForm>({ resolver: zodResolver(createProductSchema) });
  // ...
}

z.infer<typeof schema> выводит TypeScript-тип прямо из схемы — не нужно держать тип и правила отдельно и следить, чтобы они не разошлись. Одна схема — и валидация, и тип.

Согласованность с backend-контрактом

Здесь самое ценное для UCP. Та же zod-схема описывает форму данных, которую ждёт backend, — значит, граница «что отправляем» проверяется на клиенте теми же правилами, что и контракт. Схему можно держать рядом с типами фичи и переиспользовать и для формы, и для типизации запроса в TanStack Query. Расхождение формы и контракта ловится типами, а не отказом сервера.

Ошибки и сабмит

Ошибки валидации показывают рядом с полями из formState.errors. Отдельный случай — ошибки от сервера (например, «email уже занят»): их возвращает мутация, и форма показывает их так же, через setError.

{errors.name && <span className="field-error">{errors.name.message}</span>}

Клиентская валидация — для быстрой обратной связи; серверная — источник истины (клиент не доверенная сторона). Обе нужны: клиент ловит ошибки сразу, сервер гарантирует.

Где это в UCP

Форма — граница ввода: react-hook-form держит поля, zod проверяет формат, и та же схема согласует клиент с backend-контрактом. Это та же дисциплина «формат на границе, правила в домене», что в backend-биндингах: клиентская валидация проверяет форму, бизнес-правила остаются на сервере. Сквозная типобезопасность — от zod-схемы формы до контракта API — то, что позволяет продукт-инженеру менять данные продукта, не боясь рассинхрона клиента и сервера. Дальше форму надо встроить в навигацию — это роутинг.