Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Паттерн useActionState в React 19: обработка форм, pending-состояния и типизация ошибок в TypeScript

Тимофей Ищенко

Коротко: Разбираем паттерн useActionState в React 19: строгая типизация в TypeScript, обработка ошибок, pending-состояния и сравнение с React Hook Form.

Долгое время работа с формами в React требовала значительного количества шаблонного кода: отдельные хуки useState для данных, флагов загрузки и ошибок, ручные обертки try/catch внутри обработчиков onSubmit и предотвращение дефолтного поведения событий через e.preventDefault().

С релизом React 19 парадигма изменилась. Хук useActionState объединяет управление состоянием, жизненный цикл асинхронного действия и индикацию загрузки в единый декларативный интерфейс. Разберем, как устроен этот паттерн, как выстроить строгую типизацию на TypeScript и где проходят границы применимости нативного подхода.


Как работает useActionState: анатомия и механика хука

Хук useActionState (ранее развивавшийся в экспериментальных сборках как useFormState в ReactDOM) создан для синхронизации состояния компонента с результатами вызова асинхронных действий — actions.

const [state, formAction, isPending] = useActionState(
  action,
  initialState,
  permalink?
);

Сигнатура и возвращаемые значения

Хук принимает три параметра:

  1. action: функция-обработчик (action reducer). Она принимает два аргумента: предыдущее состояние (prevState) и полезную нагрузку (payload, чаще всего объект FormData или кастомные параметры). Функция может быть как синхронной, так и асинхронной.
  2. initialState: начальное состояние формы до первого вызова экшена.
  3. permalink (опционально): строка с URL, полезная при прогрессивном улучшении (progressive enhancement) и SSR, когда форма может быть отправлена до загрузки и инициализации клиентского JavaScript-бандла.

Возвращаемый кортеж содержит три элемента:

  • state: текущее состояние, обновляемое значением, которое возвращает экшен.
  • formAction: обернутая функция, передаваемая напрямую в атрибут action тега <form> или в formAction кнопки <button>.
  • isPending: булев флаг, который автоматически принимает значение true на время выполнения асинхронного экшена.

Архитектура типизации: моделирование состояния и ошибок в TypeScript

При проектировании форм на TypeScript важно избегать неопределенных состояний, когда одновременно присутствуют и данные об успехе, и ошибки валидации. Для решения этой задачи подходит паттерн дискриминантных объединений (Discriminated Unions).

Паттерн Discriminated Unions для FormState

Выделим три состояния формы: исходное (idle), успешное (success) и состояние ошибки (error):

export type FieldErrors<T> = Partial<Record<keyof T, string[]>>;

export type ActionState<TData, TFields = unknown> =
  | {
      status: 'idle';
      data: null;
      errors: null;
      message: null;
    }
  | {
      status: 'success';
      data: TData;
      errors: null;
      message: string;
    }
  | {
      status: 'error';
      data: null;
      errors: FieldErrors<TFields> | null;
      message: string;
    };

Такая структура позволяет безопасно сужать типы в UI-компонентах без избыточных проверок на null и исключает рассинхронизацию полей.

Типизация Action Reducer

Функция-экшен строго типизируется с учетом структуры ActionState и входящих данных:

export type FormActionHandler<TData, TFields> = (
  prevState: ActionState<TData, TFields>,
  formData: FormData
) => Promise<ActionState<TData, TFields>>;

Практическая реализация: форма с валидацией, лоадером и обработкой ошибок

Реализуем форму подписки пользователя с серверной или клиентской валидацией.

1. Реализация Action-функции

Экшен извлекает данные из FormData, валидирует поля и возвращает новое состояние:

// actions/subscribe.ts
'use server'; // Для сред с поддержкой React Server Actions (например, Next.js App Router)

import { ActionState } from '@/types/form';

interface SubscribeDTO {
  email: string;
  name: string;
}

interface SubscribeSuccessPayload {
  subscriptionId: string;
}

export async function subscribeAction(
  prevState: ActionState<SubscribeSuccessPayload, SubscribeDTO>,
  formData: FormData
): Promise<ActionState<SubscribeSuccessPayload, SubscribeDTO>> {
  const email = formData.get('email') as string;
  const name = formData.get('name') as string;

  // Базовая проверка
  const errors: Record<string, string[]> = {};
  if (!email || !email.includes('@')) {
    errors.email = ['Введите корректный рабочий email'];
  }
  if (!name || name.trim().length < 2) {
    errors.name = ['Имя должно содержать минимум 2 символа'];
  }

  if (Object.keys(errors).length > 0) {
    return {
      status: 'error',
      data: null,
      errors,
      message: 'Проверьте правильность заполнения полей',
    };
  }

  try {
    // Имитация асинхронного запроса к API или базе данных
    await new Promise((resolve) => setTimeout(resolve, 1200));

    return {
      status: 'success',
      data: { subscriptionId: `sub_${Date.now()}` },
      errors: null,
      message: 'Подписка успешно оформлена!',
    };
  } catch (err) {
    return {
      status: 'error',
      data: null,
      errors: null,
      message: err instanceof Error ? err.message : 'Непредвиденная ошибка сервера',
    };
  }
}

2. UI-компонент формы с интеграцией useActionState

Интегрируем экшен в компонент:

// components/SubscribeForm.tsx
'use client';

import React, { useActionState } from 'react';
import { subscribeAction } from '@/actions/subscribe';
import { ActionState } from '@/types/form';

const initialState: ActionState<{ subscriptionId: string }, { email: string; name: string }> = {
  status: 'idle',
  data: null,
  errors: null,
  message: null,
};

export function SubscribeForm() {
  const [state, formAction, isPending] = useActionState(subscribeAction, initialState);

  return (
    <form action={formAction} className="form-container">
      <div className="field-group">
        <label htmlFor="name">Имя</label>
        <input
          id="name"
          name="name"
          type="text"
          disabled={isPending}
          aria-invalid={!!state.errors?.name}
        />
        {state.errors?.name && (
          <span className="field-error">{state.errors.name.join(', ')}</span>
        )}
      </div>

      <div className="field-group">
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          disabled={isPending}
          aria-invalid={!!state.errors?.email}
        />
        {state.errors?.email && (
          <span className="field-error">{state.errors.email.join(', ')}</span>
        )}
      </div>

      {state.message && (
        <div className={`status-banner status-${state.status}`}>
          {state.message}
        </div>
      )}

      <button type="submit" disabled={isPending} className="submit-button">
        {isPending ? 'Отправка...' : 'Подписаться'}
      </button>
    </form>
  );
}

useActionState против классического useState и React Hook Form

Выбор подхода определяется сложностью интерактивной валидации на клиенте и требованиями к динамической структуре полей.

Критерий useState (ручной) useActionState (React 19) React Hook Form (RHF)
Количество boilerplate Высокое (3+ хука, try/catch) Минимальное (1 хук) Среднее (контроллеры, схемы)
Pending-состояние Ручной флаг setLoading Нативный флаг isPending Свойство isSubmitting
Интеграция с RSC Требует клиентских оберток Нативная интеграция Требует изоляции на клиенте
Сложная валидация (onChange/onBlur) Трудоемко Требует кастомных обработчиков Нативная (Zod, Yup, Resolver)
Динамические массивы полей Сложно поддерживать Требует ручного парсинга Нативно через useFieldArray

Когда достаточно нативного useActionState:

  • Стандартные формы авторизации, регистрации, профиля, обратной связи.
  • Мутации и CRUD-операции в связке с Server Actions и Next.js App Router.
  • Приложения, где критичен минимальный размер клиентского JavaScript-бандла.

Когда необходим React Hook Form:

  • Сложные многошаговые формы (визарды) с зависимыми полями.
  • Необходимость валидации полей «на лету» (посимвольно при вводе или при выходе из инпута).
  • Динамические списки с добавлением и удалением большого числа строк.

Тонкости и частые ошибки при работе с useActionState

1. Передача дополнительных аргументов через bind

Если в экшен требуется передать идентификатор сущности (например, itemId), который отсутствует в разметке формы, используется метод Function.prototype.bind:

async function deleteItemAction(itemId: string, prevState: State, formData: FormData) {
  // Логика удаления
}

// В компоненте
const deleteWithId = deleteItemAction.bind(null, item.id);
const [state, formAction, isPending] = useActionState(deleteWithId, initialState);

При этом привязанный аргумент передается первым, сдвигая prevState и formData вправо по списку параметров.

2. Сброс формы после успешной отправки

Поскольку useActionState работает с нативным объектом FormData, форма по умолчанию является неконтролируемой. Чтобы очистить инпуты после успешного ответа, используют ref формы и вызывают метод .reset() при смене статуса:

const formRef = useRef<HTMLFormElement>(null);

useEffect(() => {
  if (state.status === 'success') {
    formRef.current?.reset();
  }
}, [state.status]);

return <form ref={formRef} action={formAction}>...</form>;

3. Использование параметра permalink

Третий аргумент хука (permalink) служит для синхронизации URL при серверном рендеринге. Если отправка формы меняет страницу или форма рендерится на нескольких маршрутах, передача permalink помогает корректно сохранить контекст навигации при отправке до полной гидратации.


FAQ: Часто задаваемые вопросы

1. Чем useActionState отличается от устаревшего useFormState?

Хук useActionState заменил useFormState в официальном релизе React 19. Главное отличие: useActionState возвращает кортеж из трех элементов, включая флаг isPending. Ранее для отслеживания статуса отправки требовалось вызывать отдельный хук useFormStatus внутри дочерних компонентов формы.

2. Можно ли использовать useActionState на чистом клиенте без Server Actions?

Да. Хук работает как с Server Actions, так и с обычными клиентскими асинхронными функциями. В чисто клиентских SPA он служит удобной альтернативой связке useState и useTransition.

3. Как заблокировать повторную отправку формы во время запроса?

Флаг isPending, возвращаемый хуком, автоматически принимает значение true на весь период выполнения асинхронного экшена. Достаточно передать disabled={isPending} на кнопку отправки (<button type="submit">) и интерактивные поля ввода.

4. Как валидировать данные внутри экшена с помощью Zod?

Внутри action-функции поля извлекаются через Object.fromEntries(formData.entries()) и проверяются методом schema.safeParse(). При наличии ошибок экшен возвращает status: 'error' и объект ошибок error.flatten().fieldErrors.

5. Как программно вызвать действие без события отправки формы?

Возвращаемую функцию formAction можно вызвать напрямую в любом обработчике событий, передав в нее FormData или кастомный payload: formAction(new FormData()).


Заключение

Хук useActionState в React 19 делает архитектуру работы с формами предсказуемой и лаконичной. Объединение состояния данных, ошибок валидации и флага isPending в одном стандартном интерфейсе уменьшает объем шаблонного кода, снижает риск race conditions при асинхронных запросах и обеспечивает строгую типизацию на TypeScript. Для большинства типовых форм нативного инструментария React 19 теперь достаточно без подключения сторонних библиотек.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также