Перейти к основному контенту
Tech Path Finder
КурсыИнтервьюКод-ревьюБлог
Tech Path Finder

Персонализированный путеводитель в IT. Квизы, мок-интервью, код ревью и аналитика прогресса.

@potapov_me

Платформа

  • Курсы
  • Прогресс
  • Мок-интервью
  • Код ревью
  • Живое ревью с ИИ
  • Тренажёр переговоров
  • Закладки

Контент

  • Блог
  • Главная
  • Обратная связь

Компания

  • О проекте
  • Тарифы
  • Условия использования
  • Конфиденциальность
  • Согласие на обработку данных
  • Cookie
  • Реквизиты

Аккаунт

  • Войти
  • Зарегистрироваться
  • Профиль

© 2026 Tech Path Finder. Все права защищены.

·ИП Потапов К.С.·Политика конфиденциальности·
Сделано с ❤️ в России
  1. Формы и действия
forms_actions

Формы и действия

Управляемые поля, FormData, проверка внешних данных, действия, useActionState, useFormStatus и оптимистичные изменения.

Открыть лабораториюv0.2.0Запускается локально из публичного репозитория

Формы и действия

Форма — цельный сценарий пользовательского ввода, а не обязательный набор функций обновления на каждую клавишу.

#Результат урока

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

Понадобятся обработчики, моделирование состояния, unknown и размеченные объединения. Итог урока — форма создания заявки с проверкой в браузере и на сервере, которая не теряет данные после отказа.

#1. Нативная форма как базовый контракт

<form> объединяет поля, отправку по Enter и встроенную проверку браузера. Логика отправки должна жить в onSubmit или action, а не только в щелчке по кнопке: иначе клавиатурная отправка обойдёт код. Каждому полю нужны подпись и осмысленный name, потому что он определяет ключ в FormData.

Форма из div теряет отправку по Enter, встроенную проверку и семантику для вспомогательных технологий:

// ❌ Enter в поле ничего не делает, браузер не знает, что это форма <div> <input onChange={handleChange} /> <button onClick={handleSubmit}>Создать</button> </div> // ✅ нативная форма: Enter, submit, автозаполнение и подписи работают <form action={createTicket}> <label htmlFor="title">Заголовок</label> <input id="title" name="title" required minLength={3} /> <button type="submit">Создать</button> </form>

name здесь не оформление, а ключ данных: именно по нему значение попадёт в FormData. Поле без name не будет отправлено вообще — ни нативно, ни через действие.

Проверьте себя. Отправьте форму клавишей Enter из текстового поля и активируйте кнопку с клавиатуры.

Частая ошибка. Собрать форму из div и обработчика щелчка без нативной семантики.

#2. Управляемые и неуправляемые поля

Управляемое (controlled) поле получает value или checked и синхронный onChange; источником истины служит состояние React. Неуправляемое (uncontrolled) поле использует defaultValue и хранит значение в DOM до чтения формы. Нельзя переключать одно поле между режимами: значение управляемого поля не должно становиться undefined. Выбор зависит от того, нужен ли приложению каждый промежуточный символ.

Каждый режим уместен в своём случае:

// управляемое: нужен каждый символ для живого предпросмотра const [query, setQuery] = useState(''); <input name="query" value={query} onChange={(event) => setQuery(event.target.value)} /> // неуправляемое: значение нужно только при отправке <textarea name="comment" defaultValue={ticket.comment} />

Переключение между режимами происходит незаметно — через undefined:

// ❌ пока maybeString === undefined, поле неуправляемое; потом станет управляемым <input value={maybeString} onChange={handleChange} /> // ✅ значение всегда строка <input value={maybeString ?? ''} onChange={handleChange} />

React выдаёт предупреждение о таком переключении, но последствия хуже предупреждения: введённый пользователем текст может исчезнуть в момент, когда значение впервые станет определённым.

Проверьте себя. Сделайте управляемый поиск с живым предпросмотром и неуправляемое поле комментария, читаемое при отправке.

Частая ошибка. Передать value={maybeString} и незаметно переключить поле между управляемым и неуправляемым режимами.

#3. FormData — недоверенная граница

FormData.get возвращает строку, File или null. Числа, даты и логические значения автоматически не восстанавливаются. В браузере и на сервере вход нужно разобрать и проверить; тип функции в TypeScript не защищает от вручную сформированного HTTP-запроса. Ошибки данных возвращайте как ожидаемый результат, а системные сбои обрабатывайте отдельно.

const rawTitle = formData.get('title'); if (typeof rawTitle !== 'string' || rawTitle.trim().length < 3) { return { status: 'invalid' as const, fields: { title: 'Минимум 3 символа' } }; }

Проверка одновременно исключает null/File и применяет доменное правило. Сервер повторяет доверенную валидацию независимо от клиента.

Утверждение типа выглядит короче и не проверяет ничего:

// ❌ при отсутствующем поле получим null, при загрузке файла — File const title = formData.get('title') as string; const count = Number(formData.get('count')); // NaN не отличить от нуля

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

Проверьте себя. Отправьте отсутствующее поле, File вместо строки и пробельное название.

Частая ошибка. Писать formData.get("title") as string без проверки.

#4. Действия формы и состояние выполнения

React 19 позволяет передать функцию в action формы. Асинхронное действие управляет состоянием отправки; после успеха неуправляемая форма очищается автоматически. Функция может работать в браузере или на сервере, если это поддерживает фреймворк. Авторизация, идемпотентность и обработка системных ошибок всё равно обязательны.

Действие возвращает результат для интерфейса и не выбрасывает ожидаемые ошибки:

// src/features/tickets/actions.ts export async function createTicket(_prev: FormState, formData: FormData): Promise<FormState> { const parsed = parseTicketForm(formData); if (!parsed.ok) return { status: 'invalid', fields: parsed.fields }; // ожидаемый исход try { const created = await api.createTicket(parsed.value); return { status: 'success', id: created.id }; } catch (error) { reportError(error); // системный сбой: в журнал детали, пользователю — общее сообщение return { status: 'failure', message: 'Не удалось создать заявку. Попробуйте ещё раз.' }; } }

Три исхода разделены намеренно: ошибка ввода показывается у поля, системный сбой — с предложением повтора и сохранённым вводом. И само имя action ничего не гарантирует: клиентская функция остаётся клиентской, серверную создаёт директива use server и протокол фреймворка.

Проверьте себя. Реализуйте действие с успешным результатом, ошибкой данных и сетевым отказом.

Частая ошибка. Считать любую функцию с именем action доверенной серверной операцией.

#5. useActionState и типизированный результат

useActionState связывает действие с последним результатом и признаком выполнения. Функция получает первым аргументом предыдущее состояние, а затем входные данные. Результат удобно моделировать объединением idle | invalid | success | failure. Не передавайте пользователю объект исключения или внутренние детали сервера.

type FormState = | { status: 'idle' } | { status: 'invalid'; fields: Record<string, string> } | { status: 'success'; id: string }; const [state, submitAction, pending] = useActionState(saveRequest, { status: 'idle' });

Объединение заставляет JSX явно разобрать каждый исход, а pending описывает текущее выполнение отдельно от последнего результата.

Ошибка поля связывается с полем — иначе её не прочитает программа чтения с экрана:

<form action={submitAction}> <label htmlFor="title">Заголовок</label> <input id="title" name="title" defaultValue={lastTitle} // ввод сохранён после отказа aria-invalid={state.status === 'invalid' && !!state.fields.title} aria-describedby={state.status === 'invalid' && state.fields.title ? 'title-error' : undefined} /> {state.status === 'invalid' && state.fields.title && ( <p id="title-error">{state.fields.title}</p> )} </form>

Тип string | null на все случаи сразу здесь не работает: по нему нельзя отличить «ещё не отправляли» от «успех без сообщения» и от «ошибка».

Проверьте себя. Свяжите ошибку поля с aria-describedby и сохраните пользовательский ввод после неудачной проверки.

Частая ошибка. Использовать string | null одновременно для успеха, ошибки и отсутствия результата.

#6. useFormStatus у потомка формы

useFormStatus читает состояние родительской формы подобно контексту. Хук должен вызываться в компоненте внутри <form>; компонент, который только возвращает форму, не видит состояние ещё не созданного родителя. Результат содержит pending и данные текущей отправки, но постоянную проверку каждого символа не стоит строить на чтении FormData.

// src/SubmitButton.tsx function SubmitButton() { const { pending } = useFormStatus(); return <button disabled={pending}>{pending ? 'Сохраняем…' : 'Сохранить'}</button>; }

Кнопка находится внутри <form action={...}>, поэтому получает состояние ближайшей формы без передачи свойства через промежуточные компоненты.

Типичная ошибка — вызвать хук в том же компоненте, который отрисовывает форму:

// ❌ pending всегда false: форма ещё не является родителем этого компонента function TicketForm() { const { pending } = useFormStatus(); return <form action={createTicket}><button disabled={pending}>Создать</button></form>; }

Хук читает контекст ближайшей формы выше по дереву. В момент вызова такой формы над компонентом нет, поэтому статус всегда пустой — нужен отдельный компонент внутри формы.

Проверьте себя. Добавьте две формы и убедитесь, что pending одной не блокирует кнопку другой.

Частая ошибка. Вызвать useFormStatus рядом с JSX form и ожидать статус этой же формы.

#7. Оптимистичное состояние и согласование

useOptimistic позволяет заранее показать предполагаемый результат действия. Функция преобразования должна быть чистой. Каждой предварительной записи нужны стабильный клиентский идентификатор и статус: тогда подтверждение можно связать с серверным идентификатором, а отказ — откатить или повторить. Не показывайте необратимый успех до ответа сервера.

Клиентский идентификатор — единственный надёжный способ связать предварительную запись с ответом:

// src/features/comments/CommentList.tsx const [optimistic, addOptimistic] = useOptimistic( comments, (current: Comment[], draft: PendingComment) => [...current, { ...draft, status: 'pending' }], ); async function handleSubmit(formData: FormData) { const clientId = crypto.randomUUID(); // стабильная связь запроса и записи addOptimistic({ clientId, body: String(formData.get('body') ?? '') }); await sendComment({ clientId, body: formData.get('body') }); }

Сопоставление по индексу или тексту ломается при параллельных отправках: два ответа могут прийти в обратном порядке, а два одинаковых комментария неразличимы. И необратимые исходы — «платёж выполнен», «письмо отправлено» — не показывают предварительно: откатить такое сообщение нельзя.

Проверьте себя. Смоделируйте два параллельных предварительных добавления и ответы сервера в обратном порядке.

Частая ошибка. Сопоставлять подтверждение по индексу списка или тексту записи.

#8. Повторная отправка, идемпотентность и ошибки

Отключённая кнопка уменьшает число случайных повторов, но не заменяет идемпотентность сервера: запрос может повторить сеть, другая вкладка или сторонний клиент. Для создания записи нужен ключ идемпотентности (idempotency key) или предметная дедупликация. Ошибку данных показывайте рядом с полем; при системном отказе сохраняйте ввод и предлагайте безопасный повтор.

Ключ генерируется один раз на попытку и передаётся заголовком:

// src/features/tickets/api.ts export async function createTicket(input: TicketInput, idempotencyKey: string) { return fetch('/api/tickets', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey }, body: JSON.stringify(input), }); }

Сервер по этому ключу распознаёт повтор и возвращает результат первой операции вместо создания второй записи. Именно поэтому ключ нельзя генерировать заново при повторной отправке — иначе для сервера это новая операция.

disabled={pending} защищает только от двойного клика в одной вкладке. Повторную доставку запроса сетью, вторую вкладку и восстановление после разрыва соединения он не покрывает вообще.

Проверьте себя. Дважды отправьте одну команду с тем же ключом идемпотентности и убедитесь, что сервер создал одну запись.

Частая ошибка. Считать disabled={pending} гарантией единственного выполнения.

#Лаборатория

Реализуйте форму заявки через действие React 19. Прочитайте FormData как unknown, проверьте поля, верните их ошибки через useActionState, покажите выполнение через useFormStatus, добавьте предварительную запись с временным идентификатором и откат при отказе.

Работа готова, когда форма работает с клавиатуры, повторная отправка контролируется, ошибки связаны с полями, а предварительное состояние согласуется с подтверждённым результатом.

#Источники для сверки

  • React input reference
  • React form reference
  • React useActionState
  • React useOptimistic

Устранение неисправностей

Далее: Проектирование API компонентов