Перейти к содержанию

Типизированная форма React Hook Form и ошибки сервера

Оглавление курса · Скачать все состояния проекта.

Учебная задача: Согласовать имена полей, доступность и общую проверку без передачи доверия клиенту.

Входное состояние: Работает договор создания через Server Action и Zod. Выходное состояние: Те же поля представлены типизированной клиентской формой с серверными ошибками. Продолжаем «Смену» — учебную диспетчерскую заявок мастерской. Нужны основы JavaScript, TypeScript, React и HTTP. Используем App Router, Next 16.4.0, React 19.3.0 и Node.js не ниже 20.9.0. Основные версии закреплены; диапазоны остальных пакетов ещё не разрешены установкой и lockfile отсутствует. Полный каталог lesson-17 в архиве соответствует окончанию этой главы. Выбирайте один снимок целиком: перенос отдельных файлов из будущих этапов меняет его зависимости и правила данных.

Исходники подготовлены редакционным чтением. Пакеты, сервер, сборка, миграции, примеры и внешнее размещение не выполнялись. Ответы страницы и изменения данных ниже являются ожидаемыми, example_execution: not_run.

С ростом формы важно не только проверить значения, но и согласовать имена полей между компонентами. React Hook Form позволяет описать модель ввода и зарегистрировать элементы по её путям. Заменим интерфейс новой заявки, сохранив прежний серверный договор. Заголовок и описание остаются единственными разрешёнными полями; личность, статус и запись по-прежнему определяет серверное действие.

Используем ветку React Hook Form 7 и Zod 3. Схема ticketInput уже описывает допустимые значения. Тип TicketInput выводится из неё и передаётся useForm. Теперь имя поля в register сопоставляется со структурой типа. Это помогает заметить опечатку в исходнике до исполнения, но не доказывает, что присланное по сети значение соответствует схеме. На сервере сохраняется повторная обязательная проверка unknown.

Переиспользуемое поле без потери имени

FormField имеет обобщённый параметр T extends FieldValues. Prop name имеет тип Path<T>, а register — UseFormRegister<T>. Такая связь не разрешает произвольную строку в качестве имени при конкретной модели формы. В примере поле заголовка получает TicketInput и имя title. Если модель изменится, связанные места должны измениться вместе, а не молча отправлять лишний ключ.

useId создаёт идентификатор для связи label и input. Идентификатор элемента и имя отправляемого поля являются разными значениями. Для доступности важно htmlFor/id, для регистратора имя title. Не стоит использовать один случайный текст ради обеих задач без понимания. Если одинаковый компонент появится несколько раз, независимые id предотвращают конфликт меток.

Ошибочное поле получает aria-invalid, а описание связано через aria-describedby. Сообщение остаётся обычным текстом рядом с элементом. Назначение повторяемого компонента — согласовать эту структуру, а не скрыть все особенности будущих форм. Для описания заявки оставляем textarea отдельно: многострочный ввод отличается по смыслу от короткого заголовка. Универсальный компонент не обязан превращаться в сложную систему всех возможных виджетов.

Клиентская проверка ускоряет обратную связь

handleSubmit собирает зарегистрированные значения и вызывает асинхронный обработчик. Внутри сначала применяем ту же схему Zod. Каждая ошибка с путём title или body превращается в setError соответствующего поля. Используем явную проверку пути: не приводим любой неизвестный ключ к типу допустимого поля только ради компиляции. Ошибка общих правил могла бы иметь другой путь и потребовала бы отдельного сообщения.

Форма имеет noValidate, поэтому в этом интерфейсе выбран собственный договор клиентских сообщений, а не смесь разных браузерных всплывающих подсказок. Такой выбор не отменяет доступность и серверную проверку. Человек всё равно получает видимое объяснение у элемента. Если JavaScript не готов, эта конкретная RHF-форма не обладает тем же прогрессивным договором отправки, что предыдущая форма action; это осмысленная граница замены.

После клиентской проверки вызывается createTicketFromValues. Передаём очищенные значения, но сервер всё равно проверяет их сам. Клиентское прохождение не является доверенным свидетельством: пользователь может вызвать действие другим способом или изменить код. Общая схема уменьшает расхождение требований, не переносит полномочие в браузер. Такая повторная проверка выполняет разные роли на двух границах.

Ожидание и непредвиденный отказ

isSubmitting отключает кнопку на время асинхронного обработчика и меняет текст. В отличие от useFormStatus прежнего урока, здесь состояние принадлежит RHF. Если одновременно подключить разные механизмы ожидания без ясного источника, интерфейс может показывать противоречивый результат. Выбираем статус того инструмента, который управляет данной отправкой.

Обычный серверный отказ валидации возвращает ошибки полей. Общее сообщение помещается в root.server, где оно не связано искусственно с заголовком. Неожиданное исключение ловится и сообщает, что сохранение не подтверждено, предлагая проверить список перед повтором. Такое сообщение точнее обещания «запись не создана»: сеть могла оборваться после изменения базы и до получения ответа клиентом.

После подтверждённого успеха форма очищается и показывает сообщение. Очистка не является доказательством записи сама по себе; она выполняется только после серверного результата ok. Новый объект можно найти в списке, который серверное действие инвалидировало. Мы не выдаём локальное добавление строки за ответ базы. Если в будущем нужен переход сразу к карточке, сервер должен вернуть действительный идентификатор созданного объекта.

Согласованность интерфейсов

Предыдущий TicketForm остаётся в архиве как изученный вариант, но страница new использует TypedTicketForm. Поэтому на одной странице не появляются две независимые формы с одинаковыми id и неожиданными отправками. Оба варианта вызывают единый путь серверного сохранения. В учебном проекте компоненты оформлены обычным HTML и CSS; наличие паттерна переиспользуемого поля не означает, что был установлен или сгенерирован сторонний набор оформления.

Для самостоятельного разбора объясните, какую ошибку выявит Path<T>, какую схема Zod, а какую серверное получение личности. Затем рассмотрите отказ ответа после insert. Эти три случая показывают, почему типы, валидация и подтверждение операции нельзя заменить одним флагом «форма корректна». Форма и сервер в этой работе не выполнялись; результаты остаются ожидаемыми.

Проверьте смысл границ

Корневая ошибка формы не обязана иметь путь до конкретного поля. Например, потеря подтверждения сохранения относится ко всей отправке. Помещение её в root.server сохраняет смысл сообщения и не заставляет автора менять корректный заголовок. Ошибки схемы, напротив, указывают на определённые значения и должны быть связаны с соответствующими элементами.

Очищенная форма после успеха готова к следующей заявке, но это не автоматическое подтверждение отсутствия дублей. При необходимости редакции нужен отдельный ключ операции. Клиентский статус ожидания ограничивает обычное нажатие, а строгая уникальность предметной отправки относится к постоянному серверному хранению.

Полные файлы этого шага

Следующие файлы показаны полностью. Остальные согласованные компоненты и настройки находятся в том же каталоге архива; сохраняйте их вместе при самостоятельном прохождении.

components/form-field.tsx

"use client";

import { useId } from "react";
import type {
  FieldValues,
  Path,
  UseFormRegister,
} from "react-hook-form";

type FormFieldProps<T extends FieldValues> = {
  name: Path<T>;
  label: string;
  register: UseFormRegister<T>;
  error?: string;
};

export function FormField<T extends FieldValues>({
  name,
  label,
  register,
  error,
}: FormFieldProps<T>) {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        {...register(name)}
        aria-invalid={!!error}
        aria-describedby={error ? `${id}-error` : undefined}
      />
      <p id={`${id}-error`} className="error">{error}</p>
    </>
  );
}

components/typed-ticket-form.tsx

"use client";

import { useState } from "react";
import { useForm } from "react-hook-form";
import { createTicketFromValues } from "@/lib/actions";
import { FormField } from "@/components/form-field";
import { ticketInput } from "@/lib/validation";
import type { TicketInput } from "@/lib/validation";

export function TypedTicketForm() {
  const [message, setMessage] = useState("");
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors, isSubmitting },
  } = useForm<TicketInput>({
    defaultValues: { title: "", body: "" },
  });

  const submit = handleSubmit(async (values) => {
    setMessage("");
    const parsed = ticketInput.safeParse(values);
    if (!parsed.success) {
      for (const issue of parsed.error.issues) {
        const name = issue.path[0];
        if (name === "title" || name === "body") {
          setError(name, { message: issue.message });
        }
      }
      return;
    }

    try {
      const result = await createTicketFromValues(parsed.data);
      if (!result.ok) {
        if (result.fieldErrors?.title?.[0]) {
          setError("title", { message: result.fieldErrors.title[0] });
        }
        if (result.fieldErrors?.body?.[0]) {
          setError("body", { message: result.fieldErrors.body[0] });
        }
        setError("root.server", { message: result.message });
        return;
      }
      reset();
      setMessage("Заявка создана. Откройте список, чтобы увидеть запись.");
    } catch {
      setError("root.server", {
        message:
          "Не удалось подтвердить сохранение. " +
          "Проверьте список перед повторной отправкой.",
      });
    }
  });

  return (
    <form onSubmit={submit} noValidate>
      <FormField<TicketInput>
        name="title"
        label="Заголовок"
        register={register}
        error={errors.title?.message}
      />
      <label htmlFor="typed-body">Описание</label>
      <textarea
        id="typed-body"
        {...register("body")}
        aria-invalid={!!errors.body}
        aria-describedby="typed-body-error"
      />
      <p id="typed-body-error" className="error">
        {errors.body?.message}
      </p>
      <button disabled={isSubmitting}>
        {isSubmitting ? "Сохраняем…" : "Создать"}
      </button>
      <p role="alert">{errors.root?.server?.message}</p>
      <p role="status">{message}</p>
    </form>
  );
}

app/(work)/tickets/new/page.tsx

import { requireActor } from "@/lib/actor";
import { TypedTicketForm } from "@/components/typed-ticket-form";

export default async function NewTicket() {
  await requireActor();
  return (
    <main>
      <h1>Новая заявка</h1>
      <TypedTicketForm />
    </main>
  );
}

lib/validation.ts

import { z } from "zod";

export const ticketInput = z.object({
  title: z.string().trim()
    .min(8, "Не менее восьми символов")
    .max(160, "Не более 160 символов"),
  body: z.string().trim()
    .min(10, "Добавьте описание от десяти символов")
    .max(5000, "Не более 5000 символов"),
});
export type TicketInput = z.infer<typeof ticketInput>;
export type FormState = {
  ok: boolean;
  message: string;
  fieldErrors?: {
    title?: string[];
    body?: string[];
  };
};
export const initialState: FormState = { ok: false, message: "" };

Документация и исходники

Договор этой главы раскрыт в официальной документации. Сопоставьте её API с выбранной версией и с объяснённым источником каждого значения. Архив «Смены» содержит двадцать полных состояний и условия самостоятельной подготовки. Ожидаемый результат оценивается по текущей главе: поздние проверки владельца, база и внешние ключи не являются неявно выполненными предпосылками раннего учебного состояния.