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

Создание записи и переход

Форма уже получает ввод и готовит допустимый payload. Теперь отправим его в учебный API через POST и перейдём к созданной карточке только после подтверждения. Это первая операция серии, которая меняет серверные данные, поэтому простой совет «при ошибке повторить» здесь недостаточен.

Сервер хранит записи в памяти процесса, а клиент не имеет механизма идемпотентности. Если ответ потерялся после создания, повторная отправка может добавить ещё одну карточку. Мы явно сохраняем такое состояние как unknown и не выполняем автоматическую повторную запись.

POST начинается из события пользователя

Запрос остаётся в submit, а не переносится в useEffect. Открытие формы, изменение поля или повторная настройка эффекта в StrictMode не выражают намерение создать запись. Пользователь завершает ввод и отправляет форму; именно этот момент запускает мутацию.

Нормализация выполняется до блокировки. Если title слишком короткий, запрос вообще не начинается и поля остаются доступными. После подготовки payload блокируем повторное действие и переводим status в sending. Форма показывает отправку, а введённый снимок сохраняется для понимания результата.

Полный src/pages/CreateNote.jsx для этого этапа:

import {useEffect, useRef, useState} from 'react';
import {Link, useNavigate} from 'react-router';
import {topics, parseNote} from '../lib/models.js';
import {normalizeDraft} from '../lib/form.js';
import {requestJson, HttpError} from '../lib/api.js';
export default function CreateNote() {
  const navigate = useNavigate();
  const [draft, setDraft] = useState({title: '', body: '', topic: 'html', minutes: '15'});
  const [status, setStatus] = useState('idle');
  const [error, setError] = useState('');
  const busy = useRef(false);
  const active = useRef(true);
  const request = useRef(null);
  useEffect(() => {active.current = true; return () => {active.current = false; request.current?.abort();};}, []);
  function change(field, value) {setDraft(current => ({...current, [field]: value}));}
  async function submit(event) {
    event.preventDefault();
    if (busy.current) return;
    let payload;
    try {payload = normalizeDraft(draft);}
    catch (cause) {setError(cause.message); return;}
    busy.current = true;
    setStatus('sending'); setError('');
    const controller = new AbortController(); request.current = controller;
    const timer = setTimeout(() => controller.abort(), 5000);
    try {
      const response = await requestJson('/api/notes', {method: 'POST', signal: controller.signal,
        headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)});
      if (response.status !== 201) throw new Error('Неожиданное подтверждение');
      const saved = parseNote(response.data);
      if (saved.title !== payload.title || saved.body !== payload.body || saved.topic !== payload.topic || saved.minutes !== payload.minutes) throw new Error('Снимок не совпал');
      if (active.current) navigate(`/notes/${saved.id}`, {replace: true});
    } catch (cause) {
      if (!active.current) return;
      const rejected = cause instanceof HttpError && cause.status >= 400 && cause.status < 500;
      setStatus(rejected ? 'idle' : 'unknown');
      setError(rejected ? 'Сервер отклонил карточку. Проверьте данные.' : 'Итог записи не подтверждён. Не повторяйте её автоматически.');
      if (rejected) busy.current = false;
    } finally {clearTimeout(timer); request.current = null;}
  }
  const locked = status === 'sending' || status === 'unknown';
  return <section><h1>Новая карточка</h1><form onSubmit={submit}>
    <label htmlFor="note-title">Заголовок<input id="note-title" value={draft.title} onChange={event => change('title', event.target.value)} required minLength={3} maxLength={120} disabled={locked} /></label>
    <label htmlFor="note-body">Текст<textarea id="note-body" value={draft.body} onChange={event => change('body', event.target.value)} required minLength={20} maxLength={2000} disabled={locked} /></label>
    <label htmlFor="note-topic">Тема<select id="note-topic" value={draft.topic} onChange={event => change('topic', event.target.value)} disabled={locked}>{topics.map(topic => <option key={topic} value={topic}>{topic}</option>)}</select></label>
    <label htmlFor="note-minutes">Минуты<input id="note-minutes" type="number" min="5" max="180" step="1" value={draft.minutes} onChange={event => change('minutes', event.target.value)} required disabled={locked} /></label>
    <button type="submit" disabled={locked}>{status === 'sending' ? 'Отправляем…' : status === 'unknown' ? 'Нужна сверка результата' : 'Создать карточку'}</button>
    {error && <p role="alert" className="error">{error}</p>}
  </form><p><Link to="/">Открыть каталог</Link></p></section>;
}

Busy хранится в ref, поэтому проверка может немедленно увидеть уже начавшуюся отправку до следующего рендера. Disabled помогает пользователю понять состояние, но один только атрибут кнопки не является серверной защитой от повторов. Ref защищает текущий экземпляр обработчика; другой клиент или перезагрузка страницы остаются отдельными случаями.

Что именно отправляется

RequestJson получает method POST, Content-Type application/json и JSON.stringify(payload). Payload содержит title, body, topic и minutes; идентификатор назначается сервером. Сериализация объекта необходима: передача произвольного объекта как body не выражает согласованный JSON-документ.

Например, подготовим учебную карточку «Подписи полей формы» с темой html, пятнадцатью минутами и текстом о связи label с input. Этот ввод имеет те же ограничения, что и исходные записи. Сервер дополнительно проверит типы и длины; клиентская проверка не заставляет сервер доверять форме.

В нашем fixture успешное создание возвращает 201 и полный объект с id. Клиент требует этот статус, вызывает parseNote и сопоставляет четыре содержательных поля с payload. Только после этого созданная запись считается подтверждённой для навигации. Синтаксически валидный JSON с чужим текстом не должен автоматически открываться как наш результат.

Завершение запроса Что известно Поведение формы
201, допустимая совпавшая запись Создание подтверждено контрактом Переход к карточке
4xx от данного fixture Сервер отклонил запрос Исправление ввода доступно
5xx Подтверждения нет unknown, без автоповтора
Сетевая ошибка или отмена ожидания Ответ неизвестен unknown, сохраняем draft
201 с неподходящим JSON Подтверждение не принято unknown

Классификация 4xx опирается на реализацию нашего fixture: его проверки происходят до добавления объекта. Для другого backend такое предположение нужно подтвердить отдельно. Сам номер класса HTTP не доказывает устройство произвольной серверной транзакции.

Таймер ограничивает ожидание клиента

Через пять секунд AbortController прекращает ожидание. Это выбранный учебный бюджет, а не измеренный норматив сети. Операция могла ещё не дойти до сервера, могла выполняться или уже завершиться. Отмена fetch не откатывает добавленный объект и не является командой DELETE.

Поэтому таймаут приводит к unknown. Поля остаются с прежним draft, но блокируются вместе с кнопкой отправки. Интерфейс объясняет, что итог записи не подтверждён. Автоматический повтор мог бы создать дубль, а автоматическое объявление провала — скрыть реально появившуюся запись.

Очистка при размонтировании отменяет текущее ожидание и запрещает позднему ответу вызвать переход из уже закрытой формы. Таймер очищается в finally. Эти меры относятся к жизни клиентского компонента. На сервере нет общего operationId, журнала результата и долговременного восстановления незавершённой операции.

Если пользователь закроет вкладку во время sending, памяти формы больше не будет. Это ещё одна граница локального решения: active защищает интерфейс от поздней реакции, но не сохраняет незавершённое намерение для следующего открытия. Такой журнал нельзя заменить сообщением об ошибке. Его проектируют вместе с серверным контрактом операции и устойчивым хранением идентификатора, если продукт действительно нуждается в восстановлении.

Как проверять неопределённый результат

Ссылка «Открыть каталог» позволяет прочитать текущее серверное состояние. Если новая карточка появилась, её можно открыть и сопоставить поля. Но совпадение заголовка само по себе не доказывает, что это результат именно данного POST: пользователь мог ранее создать такую же запись.

Для строгого восстановления понадобился бы устойчивый идентификатор операции и серверная идемпотентность с проверкой владельца. Наш пример этого не реализует. Он показывает честное состояние неопределённости, а не универсальный готовый протокол записи для production.

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

Переходим по подтверждённому идентификатору

UseNavigate вызывается после проверки ответа и строит /notes/{saved.id}. Мы не используем длину массива как новый id и не предполагаем, какой UUID назначен. Подробный экран снова прочитает карточку по своему адресу, как описано в предыдущем уроке.

Опция replace заменяет текущую запись истории формы. Это осознанная договорённость примера: кнопка «Назад» не должна вести непосредственно к уже отправленной форме с прежним состоянием. Универсальная команда navigate(-1) была бы менее определённой, потому что предыдущая страница могла находиться вне приложения. API переходов описан в документации useNavigate.

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

После самостоятельного запуска допустимый POST должен увеличить серверный массив на одну запись и открыть её подробный экран. Перезапуск API возвращает исходные три записи. Исходники курса не исполнялись, а фактические задержки и результаты не измерялись. Следующим шагом добавим удаление и обработку отсутствующего экрана, сохранив различие между серверным подтверждением и неизвестным итогом.

Оглавление серии