Форма проходит проверку в браузере, сервер отклоняет заявку, а введённые данные исчезают. Соберём форму записи на консультацию: React Hook Form сохранит значения и состояние отправки, Zod проверит поля, сервер вернёт управляемую ошибку для повторной попытки.

Понадобятся основы React, асинхронные функции JavaScript и Node.js 24 или новее. У примера два процесса: Vite показывает интерфейс, локальный сервер принимает JSON. Регистрироваться во внешнем сервисе не потребуется. Данные существуют только в памяти запроса.

Создайте отдельный учебный проект

В пустой папке выполните команды. Номера после @ задают семейства совместимых версий для этого примера. Сохраните полученный package-lock.json: он фиксирует точные установленные версии для повторного запуска.

mkdir consultation-form
cd consultation-form
npm init -y
npm install react@19 react-dom@19 react-hook-form@7 zod@4 @hookform/resolvers@5
npm install --save-dev vite@7

React Hook Form связывает поля с состоянием формы. Zod описывает правила проверки данных. Между ними работает адаптер (resolver), который переводит результат Zod в ошибки React Hook Form. В примере адаптер импортируется из @hookform/resolvers/zod, а правила остаются в отдельном файле, доступном клиенту и серверу.

Создайте в корне файл index.html. Сборщик найдёт JSX по адресу в script.

<!doctype html>
<html lang="ru">
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Запись на консультацию</title>
  <div id="root"></div>
  <script type="module" src="/main.jsx"></script>
</html>

В vite.config.mjs настройте учебный прокси. Браузер отправит запрос на тот же адрес, с которого получил страницу; Vite передаст /api серверу на порту 3001.

export default {
  server: {
    host: '127.0.0.1',
    port: 5173,
    strictPort: true,
    proxy: { '/api': 'http://127.0.0.1:3001' }
  }
};

Это настройка окружения разработки. После сборки Vite такой прокси не обслуживает; рабочему приложению нужен собственный адрес API и серверная конфигурация.

Опишите правила, которые проверит и сервер

Создайте schema.mjs. У заявки два поля: имя и почта. trim() убирает пробелы по краям до проверки длины. В обработчик успешной валидации попадут уже преобразованные строки.

import { z } from 'zod';

export const consultationSchema = z.object({
  name: z.string().trim()
    .min(2, 'Введите имя: хотя бы два символа')
    .max(80, 'Сократите имя до 80 символов'),
  email: z.string().trim()
    .min(1, 'Введите почту')
    .email('Проверьте адрес: например, reader@example.test')
    .max(200, 'Сократите адрес до 200 символов')
});

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

Добавьте server.mjs. Он проверяет тип содержимого, ограничивает размер тела и возвращает одинаковый формат ошибок. Адрес busy@example.test занят. Адрес retry@example.test имитирует временный сбой ровно при первой корректной попытке с этим адресом после запуска процесса.

import { createServer } from 'node:http';
import { consultationSchema } from './schema.mjs';

let retryFailed = false;
const reply = (res, status, body) => {
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(JSON.stringify(body));
};

createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/api/consultations') {
    return reply(res, 404, { message: 'Маршрут не найден' });
  }
  if (!req.headers['content-type']?.startsWith('application/json')) {
    return reply(res, 415, { message: 'Ожидается JSON' });
  }
  try {
    const chunks = [];
    let size = 0;
    for await (const chunk of req) {
      size += chunk.length;
      if (size > 4096) {
        return reply(res, 413, { message: 'Слишком большой запрос' });
      }
      chunks.push(chunk);
    }
    const body = Buffer.concat(chunks).toString('utf8');
    let input;
    try { input = JSON.parse(body); }
    catch { return reply(res, 400, { message: 'Повреждён JSON' }); }
    const result = consultationSchema.safeParse(input);
    if (!result.success) {
      const fields = {};
      for (const issue of result.error.issues) {
        const key = issue.path[0];
        if ((key === 'name' || key === 'email') && !fields[key]) {
          fields[key] = issue.message;
        }
      }
      return reply(res, 422, { fields });
    }
    await new Promise(resolve => setTimeout(resolve, 700));
    if (result.data.email === 'busy@example.test') {
      return reply(res, 422, { fields: { email: 'Эта почта уже записана' } });
    }
    if (result.data.email === 'retry@example.test' && !retryFailed) {
      retryFailed = true;
      return reply(res, 503, { message: 'Сервис временно недоступен. Повторите отправку' });
    }
    reply(res, 201, { message: 'Заявка принята. Мы напишем на указанную почту' });
  } catch {
    if (!res.writableEnded) reply(res, 500, { message: 'Не удалось принять заявку' });
  }
}).listen(3001, '127.0.0.1', () => {
  console.log('Учебный API: http://127.0.0.1:3001');
});

Сервер считает размер исходных байтов и складывает фрагменты запроса в chunks. Только после получения всего тела Buffer.concat(chunks).toString('utf8') декодирует текст. Иначе кириллическая буква, разделённая между двумя сетевыми фрагментами, могла бы превратиться в символы замены ещё до валидации. Лимит 4096 байт сохраняется.

Переменная retryFailed нужна для воспроизводимого упражнения. После перезапуска сервера первая попытка снова завершится с 503. В многопользовательском приложении такая глобальная переменная не подходит для учёта операций.

Соедините поля, ошибки и отправку

Создайте main.jsx. register добавляет полю обработчики и ссылку на DOM-элемент. Ссылка нужна React Hook Form в том числе для перевода фокуса к ошибке. Поэтому результат register передаётся непосредственно в input.

import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { consultationSchema } from './schema.mjs';
import './style.css';

function ConsultationForm() {
  const [notice, setNotice] = useState('');
  const {
    register, handleSubmit, setError, clearErrors,
    formState: { errors, isSubmitting }
  } = useForm({
    resolver: zodResolver(consultationSchema),
    defaultValues: { name: '', email: '' },
    mode: 'onSubmit',
    reValidateMode: 'onChange',
    shouldFocusError: true
  });

  function submit(event) {
    setNotice('');
    clearErrors('root');
    return handleSubmit(send)(event);
  }

  async function send(values) {
    try {
      const response = await fetch('/api/consultations', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(values)
      });
      let data;
      try { data = await response.json(); }
      catch { throw new Error('Сервер вернул непонятный ответ. Повторите позже'); }
      if (response.status === 422 && data.fields) {
        let first = true;
        for (const field of ['name', 'email']) {
          if (typeof data.fields[field] === 'string') {
            setError(field, {
              type: 'server', message: data.fields[field]
            }, { shouldFocus: first });
            first = false;
          }
        }
        if (first) throw new Error('Сервер отклонил заявку. Повторите отправку');
        return;
      }
      if (!response.ok) {
        throw new Error(data.message || 'Не удалось отправить. Попробуйте ещё раз');
      }
      setNotice(data.message);
    } catch (error) {
      setError('root.server', {
        type: 'server',
        message: error instanceof TypeError
          ? 'Нет связи с сервером. Проверьте подключение и повторите'
          : error.message || 'Не удалось отправить. Повторите попытку'
      });
    }
  }

  return (
    <main>
      <h1>Запись на консультацию</h1>
      <form noValidate onSubmit={submit}>
        <p>Оба поля обязательны.</p>
        <label htmlFor="name">Имя</label>
        <input id="name" autoComplete="name" required readOnly={isSubmitting}
          {...register('name')}
          aria-invalid={Boolean(errors.name)}
          aria-describedby={errors.name ? 'name-error' : undefined} />
        <p id="name-error">{errors.name?.message}</p>

        <label htmlFor="email">Почта</label>
        <input id="email" type="email" autoComplete="email" required readOnly={isSubmitting}
          {...register('email')}
          aria-invalid={Boolean(errors.email)}
          aria-describedby={errors.email ? 'email-hint email-error' : 'email-hint'} />
        <p id="email-hint">Например, reader@example.test</p>
        <p id="email-error">{errors.email?.message}</p>

        <div role="alert">{errors.root?.server?.message}</div>
        <p role="status">{notice || (isSubmitting ? 'Отправляем заявку…' : '')}</p>
        <button type="submit" disabled={isSubmitting}>
          {isSubmitting ? 'Отправляем…' : 'Записаться'}
        </button>
      </form>
    </main>
  );
}

createRoot(document.getElementById('root')).render(<ConsultationForm />);

В style.css оставьте заметную рамку фокуса. Ошибка обозначена и текстом, и атрибутом; цвет служит дополнительной подсказкой.

body { font: 1.1rem/1.5 system-ui; margin: 2rem; }
main { max-width: 36rem; }
label, input, button { display: block; }
input, button { font: inherit; padding: .5rem; max-width: 100%; box-sizing: border-box; }
input[aria-invalid="true"] { border: 2px solid #a00020; }
input:focus-visible, button:focus-visible { outline: 3px solid #114cc4; outline-offset: 3px; }
button { margin-block-start: 1rem; }

Форма использует noValidate, чтобы встроенное всплывающее сообщение браузера не перехватило отправку до React Hook Form. Атрибут required остаётся: он сообщает обязательность поля вспомогательным технологиям. Проверку выполняет схема.

handleSubmit сначала запускает адаптер. При клиентской ошибке функция send не вызывается. После исправления поля reValidateMode: 'onChange' обновляет ошибку при вводе. Серверная ошибка почты тоже может исчезнуть после ввода: это означает только успешную локальную проверку. Доступность адреса снова подтвердит сервер при отправке.

При новой отправке submit очищает старое подтверждение до вызова handleSubmit: сообщение о прежнем успехе исчезнет даже тогда, когда адаптер отклонит новые значения и send не запустится. Пока запрос выполняется, оба поля доступны для фокуса и чтения, но защищены от редактирования через readOnly={isSubmitting}. Поэтому ответ сервера относится к тем значениям, которые остаются видны в форме.

Возвращайте из обработчика промис запроса: здесь send объявлена async и ожидает fetch. Благодаря этому isSubmitting остаётся истинным до завершения обработки. Вариант с отдельным вызовом fetch без await завершил бы обработчик раньше и преждевременно разблокировал кнопку.

Общая ошибка находится в постоянно существующем role="alert". Его содержимое меняется при сбое. Для спокойного состояния загрузки и успешной отправки предназначен role="status". Сообщения конкретных полей не озвучиваются одновременно через несколько живых областей: фокус переходит к первому ошибочному полю, а aria-describedby связывает его с пояснением. Реальное озвучивание проверьте своим экранным диктором.

Воспроизведите отказ и повторную попытку

В первом терминале запустите API, во втором — Vite из той же папки:

node server.mjs
npx vite

Откройте http://127.0.0.1:5173. Нажмите «Записаться» с пустыми полями. Появятся две подсказки, фокус перейдёт в имя. В инструментах разработчика, во вкладке сети, запроса к /api/consultations быть не должно.

Введите имя Анна и почту busy@example.test. После отправки сервер вернёт 422, сообщение «Эта почта уже записана» окажется у почты. Имя сохранится. Для клиента адрес синтаксически корректен; ограничение обнаружилось при серверной проверке.

Теперь замените почту на retry@example.test. При первой отправке появится общая ошибка сервиса, кнопка снова станет доступной. Нажмите её ещё раз с теми же значениями. Вы увидите сообщение о принятой заявке. Очищать поля при ошибке здесь нельзя: повторная попытка должна использовать введённые данные.

Действие Результат в интерфейсе Что проверить
Отправить пустую форму Ошибки у обоих полей Фокус в имени, запроса нет
Указать занятую почту Ошибка у почты 422 и сохранённое имя
Первый раз отправить retry@example.test Общая ошибка 503, повтор доступен
Отправить этот адрес повторно Заявка принята 201, общая ошибка исчезла
Остановить API и отправить форму Сообщение о сбое Значения на месте, кнопка доступна

При остановленном API прокси Vite может вернуть ответ об ошибке, который не является JSON. Тогда response.json() завершится ошибкой синтаксиса. Внутренний catch заменит её понятным сообщением «Сервер вернул непонятный ответ. Повторите позже». Настоящий ответ API тоже требует проверки формата: код HTTP сам по себе не гарантирует корректный JSON.

После проверки остановленного API снова запустите node server.mjs в первом терминале. Если используете retry@example.test, после перезапуска сервера повторите путь через 503 до успеха.

Проверьте ещё два перехода. Во время отправки попробуйте изменить почту: до ответа она должна оставаться прежней. После успеха очистите имя и отправьте форму снова: старое подтверждение исчезает, поле получает ошибку. Запрос к серверу не уходит.

Проверьте сервер независимо от страницы. Следующая команда должна получить 422; клиентская схема здесь вообще не участвует:

curl -i http://127.0.0.1:3001/api/consultations \
  -H 'Content-Type: application/json' \
  --data '{"name":"А","email":"wrong"}'

Проверьте изменение правила и сохранность данных

Сначала управляемое изменение: в schema.mjs увеличьте минимальную длину имени с двух до трёх символов и обновите сообщение. Перезапустите сервер, потому что Node в этой команде не следит за файлом. Обе проверки должны отклонять Ян. Если результат расходится, проверьте, какой процесс использует старую схему.

Для самостоятельной работы добавьте необязательное поле «Комментарий» с ограничением 300 символов. Проверьте границу: строка из 300 символов проходит, из 301 — сохраняется в поле и получает пояснение. Дополните серверное сопоставление ошибок новым разрешённым именем поля.

Затем пройдите существующую форму клавиатурой: Tab до имени, ввод, Tab до почты, ввод занятого адреса, Tab до кнопки, Enter. После 422 фокус должен оказаться у почты, а подпись и ошибка — читаться вместе. Проверьте, что рамка фокуса видна и при увеличении страницы.

Если нужно закрепить, где React хранит значение поля, пригодится материал «Управляемые и неуправляемые формы React». Практику работы с состоянием и пользовательскими действиями можно продолжить на курсе по React.