PAS7 Studio
До всіх статей

useActionState: глибоке занурення — мутації, optimistic UI, черга Actions і коли data layer все ще перемагає (React 19+)

Практичний deep dive у React 19 useActionState: як працює черга Actions, чому Transitions критичні для isPending, progressive enhancement через permalink, інтеграція useOptimistic для миттєвого UX, скасування через AbortController і фреймворк прийняття рішень — коли TanStack Query / SWR / RTK Query мають залишатися власниками кешу, ретраїв та інвалідації.

27 лют. 2026 р.· 13 хв читання· Технології
Кому підійдеFrontend інженериTech leadsКоманди, що оновлюються до React 19
Студійне фото блокнота: черга action, нотатки про optimistic UI та стрілки React 19 форм

Цей розділ про коректність, UX і межі відповідальності: що useActionState гарантує, чого не гарантує, і як не перетворити його на напівготовий data layer.

Ментальна модель для Actions + useActionState (який state зберігається і де виконується логіка). [1][2]
Черга Actions: послідовне виконання, чому це прибирає частину race-багів і коли стає перфоманс-пасткою. [2]
Чому isPending залежить від Transitions, і два безпечні способи тригерити actions. [2]
Progressive enhancement: permalink, серверні відповіді до завершення hydration і що зазвичай бере на себе фреймворк. [2][6]
Optimistic UX з useOptimistic: миттєвий UI, rollback і як уникати stale optimistic state. [1][3]
Cancellation-патерни з AbortController, коли користувач може «заспамити» actions. [2]
Фреймворк вибору: коли TanStack Query / SWR / RTK Query мають залишатися власниками кешу, ретраїв, інвалідації та синхронізації. [7][8][9]
Наступний розділ: deep dive у React <Activity /> (збереження state + поведінка Effects + rollout-пастки). [13]

У більшості застосунків є дві головні задачі: показувати дані та змінювати дані. Завантаження часто простіше; складність живе в мутаціях — коректність, UX, дебаг і контроль станів.

Напрям «Actions» у React 19 фокусується на повторюваній «рукостисканні» для мутацій: pending-стан, optimistic UI та обробка помилок без самописних автоматів станів під кожну форму чи кнопку. [1]

useActionState — тонкий, але важливий шар: він обгортає Action, зберігає останній результат як state і дає isPending, щоб UI залишався чутливим під час async-роботи. Плюс — ключова семантика: dispatch-и ставляться в чергу і виконуються послідовно. [2]

Якщо команді треба запам’ятати лише одну річ — нехай це буде цей флоу. Більшість плутанини зникає, коли ownership стає явним.

01

Намір користувача входить через Action

Submit форми або ручний виклик dispatcher запускає мутаційний флоу. Якщо викликаєте dispatcher самі, це має бути всередині startTransition. [2]

02

React трекає pending і ставить наступні dispatch у чергу

Кожен новий dispatch чекає попередній, а кожне виконання отримує останній завершений результат як previousState. [2]

03

Action повертає UI state, а не кеш

Використовуйте returned state для повідомлень, field errors, id та локального post-submit UX. Не перетворюйте його тихо на спільне сховище даних.

04

Shared data ownership лишається в іншому місці

Якщо мутація впливає на кілька маршрутів/запитів/споживачів, делегуйте інвалідацію та синхронізацію framework cache-моделі або data layer. [6][7][8][9]

Висновок

Сприймайте useActionState як тонку UI-обгортку навколо мутації, а не як систему істини.

Сигнатура reducer-подібна: action спочатку отримує previousState, потім payload (для форм payload часто FormData). Те, що ви повернете, стане наступним state. [2]

React ставить кілька dispatch-ів у чергу й виконує їх послідовно. Кожен запуск бачить попередній результат як previousState. Це фіча коректності, але й потенційний backlog, якщо користувач тригерить actions швидше, ніж вони завершуються. [2]

Transitions — не опція. Якщо ви викликаєте dispatcher вручну, він має бути всередині Transition (startTransition). Якщо ви передаєте його в action / formAction, React загорне виклик у Transition за вас. Інакше isPending поводиться не так, як очікується, і React може попереджати. [2]

Обробка помилок — пастка: якщо action кидає exception, React може пропустити наступні queued dispatch-и. На практиці для очікуваних фейлів (валідація, 4xx) краще повертати error-state, а не throw. Throw залишайте для виняткових ситуацій під error boundary. [2]

Progressive enhancement закладений в API: permalink для RSC-сумісних фреймворків, щоб форма працювала ще до завантаження JS. З Server Functions initial state також має бути серіалізованим. [2]

Це помилки, через які useActionState здається гіршим, ніж є. API маленький; проблема зазвичай у неправильному використанні.

Викликати dispatcher поза startTransition і потім дивуватись, чому isPending вводить в оману. [2]

Кидати (throw) валідаційні або очікувані 4xx-помилки замість повернення структурованого error state. [2]

Пхати великі payload-и в action state і випадково перетворити UI hook на псевдо-кеш.

Використовувати його для high-frequency взаємодій без стратегії для queue backpressure, оптимізму або скасування. [2][3]

Висновок

Більшість болю rollout-у виникає від змішування відповідальностей, а не від самого hook-а.

Найшвидший виграш — форми: серверні помилки, помилки полів і pending-стан, який надійно блокує submit без проп-дрилінгу. React у своїх прикладах підсвічує цей флоу як першокласний. [1][2][4]

Практичний прийом — описати результат як discriminated union, щоб UI ніколи не «вгадував», що всередині state.

TSX
import { useActionState } from "react";

type FieldErrors = { email?: string; password?: string };

type SubmitState =
  | { kind: "idle" }
  | { kind: "error"; message: string; fieldErrors?: FieldErrors }
  | { kind: "success"; message: string };

async function register(
  _prev: SubmitState,
  formData: FormData,
): Promise<SubmitState> {
  const email = String(formData.get("email") ?? "").trim();
  const password = String(formData.get("password") ?? "");

  const fieldErrors: FieldErrors = {};
  if (!email.includes("@")) fieldErrors.email = "Enter a valid email.";
  if (password.length < 8) fieldErrors.password = "Use at least 8 characters.";

  if (fieldErrors.email || fieldErrors.password) {
    return { kind: "error", message: "Fix the highlighted fields.", fieldErrors };
  }

  const res = await fetch("/api/register", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ email, password }),
  });

  if (!res.ok) {
    return { kind: "error", message: "Registration failed. Try again." };
  }

  return { kind: "success", message: "Account created." };
}

export function RegisterForm() {
  const [state, action, isPending] = useActionState<SubmitState, FormData>(
    register,
    { kind: "idle" },
  );

  return (
    <form action={action} className="space-y-3">
      <div className="space-y-1">
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" />
        {state.kind === "error" && state.fieldErrors?.email ? (
          <p role="alert">{state.fieldErrors.email}</p>
        ) : null}
      </div>

      <div className="space-y-1">
        <label htmlFor="password">Password</label>
        <input id="password" name="password" type="password" />
        {state.kind === "error" && state.fieldErrors?.password ? (
          <p role="alert">{state.fieldErrors.password}</p>
        ) : null}
      </div>

      <button type="submit" disabled={isPending}>
        {isPending ? "Creating…" : "Create account"}
      </button>

      {state.kind === "error" ? <p role="alert">{state.message}</p> : null}
      {state.kind === "success" ? <p>{state.message}</p> : null}
    </form>
  );
}

Один важливий нюанс форм: form Actions у React 19 можуть скидати uncontrolled inputs після успішного submit, а для ручного ресету є requestFormReset. Якщо робите флоу на кшталт «зберегти чернетку», обов’язково протестуйте uncontrolled vs controlled. [1]

Якщо у вас є компонентна бібліотека (Button, SubmitButton, FormFooter), протягувати isPending крізь ієрархію — нудно. useFormStatus читає статус батьківської форми як контекст і створений саме для цього. [4]

TSX
import { useFormStatus } from "react-dom";

type Props = { label: string };

export function SubmitButton({ label }: Props) {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Submitting…" : label}
    </button>
  );
}

Optimistic UI — це не «прискоримо, удаючи». Це «покажемо очікуваний фінальний стан під час запиту і вмітимемо коректно відкотитися на фейлі». useOptimistic у React 19 задуманий як пара до Actions: optimistic state існує під час Action, а після завершення/помилки React повертається до source-of-truth. [1][3]

Ключовий трюк із документації: якщо ви використовуєте reducer useOptimistic(items, reducer), React може повторно прогнати reducer, коли базовий items зміниться під час pending action. Це зменшує баги зі stale optimistic state в колаборативних/реалтаймних UI. [3]

TSX
import { useActionState, useOptimistic } from "react";

type Todo = { id: string; title: string; pending?: true };

type AddState = { lastError: string | null };

type ApiResult =
  | { ok: true; created: { id: string; title: string } }
  | { ok: false; error: string };

async function addTodoOnServer(title: string): Promise<ApiResult> {
  const res = await fetch("/api/todos", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ title }),
  });

  if (!res.ok) return { ok: false, error: "Failed to create todo." };

  const data: unknown = await res.json();
  const created = data as { id?: unknown; title?: unknown };
  if (typeof created.id !== "string" || typeof created.title !== "string") {
    return { ok: false, error: "Bad response." };
  }

  return { ok: true, created: { id: created.id, title: created.title } };
}

function optimisticReducer(
  state: Todo[],
  action:
    | { type: "add"; todo: Todo }
    | { type: "replace"; tempId: string; real: Todo },
) {
  if (action.type === "add") return [action.todo, ...state];
  return state.map(t => (t.id === action.tempId ? action.real : t));
}

export function TodoComposer({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, applyOptimistic] = useOptimistic(todos, optimisticReducer);

  const [state, submit, isPending] = useActionState<AddState, FormData>(
    async (prev, formData) => {
      const title = String(formData.get("title") ?? "").trim();
      if (!title) return { lastError: "Title is required." };

      const tempId = `temp:${Date.now()}`;
      applyOptimistic({ type: "add", todo: { id: tempId, title, pending: true } });

      const result = await addTodoOnServer(title);
      if (!result.ok) return { lastError: result.error };

      applyOptimistic({
        type: "replace",
        tempId,
        real: { id: result.created.id, title: result.created.title },
      });

      return { lastError: null };
    },
    { lastError: null },
  );

  return (
    <div className="space-y-3">
      <form action={submit} className="flex gap-2">
        <input name="title" placeholder="Add a todo…" />
        <button type="submit" disabled={isPending}>Add</button>
      </form>

      {state.lastError ? <p role="alert">{state.lastError}</p> : null}

      <ul className="space-y-1">
        {optimisticTodos.map(t => (
          <li key={t.id}>
            {t.title}{t.pending ? " (saving…)" : ""}
          </li>
        ))}
      </ul>
    </div>
  );
}

Черга Actions — одна з недооцінених деталей. Послідовний dispatch прибирає частину race-багів (пізні відповіді не перетирають нові наміри). Але він же може створити backlog: «клік-спам» серіалізує мережеві виклики й робить UI повільним. React прямо рекомендує useOptimistic, cancellation або інший підхід для складних кейсів. [2][3]

Cancellation корисний, коли вам потрібен «latest intent wins», але це не безкоштовно: abort мережевого запиту не перемотує серверну мутацію. Це безпечніше, коли сайд-ефект ідемпотентний, ігнорований або ретраїться без ризику. [2]

TSX
import { startTransition, useActionState, useRef } from "react";

type State = { value: number; error: string | null };

type Payload = { delta: number };

async function postDelta(delta: number, signal: AbortSignal): Promise<number> {
  const res = await fetch("/api/counter", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ delta }),
    signal,
  });

  if (!res.ok) throw new Error("Request failed");

  const data: unknown = await res.json();
  const next = (data as { value?: unknown }).value;
  if (typeof next !== "number") throw new Error("Bad response");

  return next;
}

export function Stepper() {
  const abortRef = useRef<AbortController | null>(null);

  const [state, dispatch, isPending] = useActionState<State, Payload>(
    async (prev, payload) => {
      abortRef.current?.abort();
      const controller = new AbortController();
      abortRef.current = controller;

      try {
        const nextValue = await postDelta(payload.delta, controller.signal);
        return { value: nextValue, error: null };
      } catch (e) {
        const message = e instanceof Error ? e.message : "Unknown error";
        return { value: prev.value, error: message };
      }
    },
    { value: 0, error: null },
  );

  function bump(delta: number) {
    startTransition(() => dispatch({ delta }));
  }

  return (
    <div className="space-y-2">
      <div>Value: {state.value}{isPending ? " (updating…)" : ""}</div>
      <div className="flex gap-2">
        <button onClick={() => bump(-1)}>-</button>
        <button onClick={() => bump(1)}>+</button>
      </div>
      {state.error ? <p role="alert">{state.error}</p> : null}
    </div>
  );
}

React docs: AbortController може скасувати pending Actions, але abort запиту не «відкочує» серверну мутацію. [2]

Скріншот секції queued-actions-and-cancellation

Чиста межа така: useActionState класний для UI-власного «рукостискання» мутації; data layers сильні в системній коректності даних (кеш, ретраї, інвалідація, синхронізація між екранами).

React 19 дає кращий примітив мутацій, але він свідомо не намагається замінити відповідальності data library. Вам все одно потрібно вирішити, хто володіє кешем і синхронізацією. [1][2]

Проста таблиця рішень (евристика, не догма):

MD
| Потреба | useActionState підходить | Data layer підходить |
|---|---|---|
| Pending + field/server errors у формах | ✅ | ✅ |
| Простий одноразовий mutation з локальним UI апдейтом | ✅ | ✅ |
| Кешування + dedupe + background refetch | ❌ | ✅ (SWR / TanStack Query) |
| Retry / backoff політики | ❌ | ✅ |
| Інвалідація кешу між багатьма екранами | ❌ | ✅ |
| Offline черги / persistence | ❌ | ✅ |
| Optimistic UI локального масштабу | ✅ (з useOptimistic) | ✅ |
| Optimistic UI + reconciliation зі спільним кешем | ⚠️ | ✅ |

Якщо мутація впливає на багато запитів або екранів, зазвичай краще, щоб інвалідацією та refetch володіла бібліотека, яка моделює цей граф. TanStack Query підсвічує invalidation як базовий workflow для мутацій; RTK Query робить те саме через теги; SWR має примітиви mutation + revalidation з подібних причин. [7][9][8]

Де useActionState сяє

UI-власні мутаційні флоу: форми, налаштування, візарди, кнопки “save”, компоненти, де result state потрібен локально (і, можливо, редірект). Менше boilerplate, кращі UX-дефолти. [1][2]

Де data layer перемагає

Системна коректність даних: спільні кеші, оркестрація refetch, ретраї, dedupe, offline, консистентність між роутами. Це нетривіальні відповідальності, які useActionState не моделює. [7][8][9]

Поширений гібрид

Використовуйте useActionState як UI-оболонку (pending + errors), але інвалідацію/рефетч віддайте data layer (або кеш-моделі фреймворку в Next.js). Так межі залишаються чистими. [6][7]

Comparison pointuseActionState підходитьData layer підходить
Pending форми + field/server errorsТакТак
Проста one-off мутація з локальним UI updateТакТак
Кешування + дедуп + background refetchНіТак (SWR / TanStack Query)
Політики retry / backoffНіТак
Інвалідація кешу на багатьох екранахНіТак
Offline черги / persistenceНіТак
Optimistic UI для малого локального scopeТак (з useOptimistic)Так
Optimistic UI + reconciliation спільного кешуРизикованоТак

Практичний гібрид: useActionState для success/error на рівні форми, а TanStack Query — для invalidation, щоб інші екрани відобразили зміни. У документації TanStack показує invalidateQueries як стандартний крок після мутації і зазначає, що Promise в onSuccess тримає мутацію «pending», поки invalidation не завершиться. [7]

TSX
import { useActionState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";

type State = { ok: boolean; message: string };

type Payload = { title: string };

declare function addTodo(input: Payload): Promise<void>;

export function AddTodo() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: addTodo,
    onSuccess: async () => {
      await queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });

  const [state, dispatch, isPending] = useActionState<State, FormData>(
    async (_prev, formData) => {
      const title = String(formData.get("title") ?? "").trim();
      if (!title) return { ok: false, message: "Title is required." };

      try {
        await mutation.mutateAsync({ title });
        return { ok: true, message: "Saved." };
      } catch {
        return { ok: false, message: "Failed to save." };
      }
    },
    { ok: true, message: "" },
  );

  return (
    <form action={dispatch} className="flex gap-2">
      <input name="title" placeholder="New todo" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Saving…" : "Save"}
      </button>
      {state.message ? <span>{state.message}</span> : null}
    </form>
  );
}

TanStack Query: invalidation після мутацій через onSuccess + invalidateQueries (Promise.all для кількох ключів). [7]

Скріншот секції integration-example-react-query

Більшість регресій з’являються, коли змішують відповідальності або ігнорують семантику: transitions, queueing, resets і розмір state.

Практична евристика: зберігайте в action state лише те, що потрібно UI після мутації (errors, success, ids). Довговічні та спільні дані нехай залишаються на сервері, в кеші фреймворку або в data layer. [1][6][7]

необхідна

Ручний dispatch має бути в startTransition, інакше React попереджає, а isPending поводиться не так, як очікується. action/formAction загортає за вас. [2]

стережіться спаму

Послідовний dispatch прибирає частину race-багів, але може серіалізувати інтеракції в backlog. Для rapid-fire використовуйте useOptimistic, cancellation або інший підхід. [2][3]

тримайте малим

Сприймайте action state як UI state (errors, messages, ids), а не як кеш. Великі об’єкти збільшують вартість рендеру та ускладнюють reconciliation.

тестуйте uncontrolled

React 19 form Actions можуть скидати uncontrolled inputs після успіху; requestFormReset існує для ручних сценаріїв. Чернетки/прогрес потребують продуманого підходу. [1]

Якщо ви впроваджуєте useActionState по всій кодовій базі — ці перевірки прибирають типові пастки коректності та UX.

  • Моделюйте результат action як discriminated union (уникайте неясних “може рядок, може об’єкт”).

  • Для очікуваних фейлів повертайте error-state, не кидайте exceptions — щоб не пропускати queued actions. [2]

  • Ручний dispatch має бути в startTransition, інакше isPending вводить в оману. [2]

  • Для спамних інтеракцій оберіть: послідовна коректність, cancellation (AbortController) або optimistic reducer. [2][3]

  • Якщо мутація впливає на кілька екранів — віддайте invalidation/refetch у data layer (або Next cache model). [7][9][6]

  • Для Server Functions перевірте серіалізованість initial state і прогоніть progressive enhancement (permalink / pre-hydration submits). [2][6]

  • Перевірте reset поведінку uncontrolled inputs і вирішіть, чи потрібні controlled inputs або явний reset. [1]

useActionState замінює TanStack Query / SWR / RTK Query?

Ні. useActionState чудовий для UI-рівня (pending + result + errors). Data layers все ще володіють кешем, ретраями, інвалідацією, background refetch і синхронізацією між екранами. Якщо мутація впливає на кілька queries/routes, data layer (або кеш-модель фреймворку на кшталт Next.js) зазвичай правильний власник. [7][8][9][6]

Чому `isPending` не оновлюється так, як я очікую?

Якщо ви викликаєте dispatcher вручну, він має бути всередині Transition (`startTransition`). Або передайте його як `action`/`formAction` — React загорне виклик у Transition за вас. Інакше React може попереджати, а `isPending` працюватиме некоректно. [2]

Actions справді послідовні? Що це дає?

Так: React ставить dispatch-и в чергу і виконує їх послідовно, тож кожне виконання бачить попередній результат як `previousState`. Це прибирає частину out-of-order багів, але може створювати backpressure у спамних флоу. [2]

Як уникнути backlog черги для швидких інтеракцій?

Використовуйте `useOptimistic` для миттєвого UI, скасовуйте pending роботу через `AbortController`, коли потрібен “latest intent wins”, або перегляньте, чи useActionState взагалі підходить для цього interaction. React у документації прямо описує ці escape hatches. [2][3]

Як безпечно обробляти помилки в reducerAction?

Для очікуваних помилок (валідація, 4xx) повертайте error-state замість throw. Якщо action кидає exception, React може пропускати наступні queued dispatch-и. Throw залишайте для виняткових кейсів під error boundaries. [2]

Що таке permalink і чи він мені потрібен?

`permalink` орієнтований на RSC-фреймворки з progressive enhancement. Якщо форма сабмітиться до завантаження JS, браузер може навігуватися на permalink-роут, не покладаючись на поточний URL. Фреймворки часто беруть це на себе, але це важливо, якщо ви хочете “працює до JS”. [2][6]

Чому поля форми очистилися після успішного submit?

React 19 form Actions можуть автоматично скидати uncontrolled inputs після успіху. Також є `requestFormReset` для ручних reset-сценаріїв. Для draft/save-progress флоу варто свідомо обрати uncontrolled або controlled inputs і протестувати поведінку. [1]

Ми додаємо лише ті джерела, які напряму підтверджують семантику, застереження та інтеграційні твердження в цьому розділі.

Перевірено: 06 бер. 2026 р.Актуально для: React 19.xАктуально для: Next.js App RouterАктуально для: Server Functions / Server ActionsПеревірено з: useActionStateПеревірено з: useOptimisticПеревірено з: useFormStatusПеревірено з: TanStack Query v5

useActionState сильний, коли межі відповідальності чисті: UI володіє «рукостисканням» мутації, а система — коректністю спільних даних.

Якщо ви оновлюєтеся до React 19 (або вводите Server Actions, optimistic UX і cache-стратегію), PAS7 Studio може зробити аудит мутаційних флоу, визначити межі ownership і допомогти з поетапним rollout із guardrails.

Ви тут02/04

useActionState deep dive: mutation flows, optimistic UI та integration patterns

Пов'язані статті

growth

AI SEO / GEO у 2026: ваші наступні клієнти — не люди, а агенти

Пошук зміщується від кліків до відповідей. Боти та AI-агенти сканують, цитують, рекомендують і дедалі частіше купують. Дізнайтесь, що таке AI SEO / GEO, чому класичного SEO вже недостатньо, і як PAS7 Studio допомагає брендам перемагати у «агентному» вебі.

blogs

Найпотужніший чіп від Apple? M5 Pro і M5 Max б'ють рекорди

Аналітичний розбір Apple M5 Pro і M5 Max станом на березень 2026 року. Пояснюємо, чому ці чіпи можна вважати найпотужнішими професійними ноутбучними SoC від Apple, як вони виглядають на тлі M4 Pro, M4 Max, M1 Pro, M1 Max і що показують у порівнянні з актуальними Intel та AMD.

blogs

Artemis II і код, який веде до Місяця

У цьому блозі розбираємо місію NASA Artemis II, яка стартувала 1 квітня 2026 року, і пояснюємо, що вона насправді говорить про сучасну інженерію: бортове ПЗ, резервні контури, симуляції, телеметрію, людський контроль і дуже обережну роль ШІ в космічній сфері.

telegram-media-saver

Автоматичне тегування та пошук збережених посилань

Інтеграція з GDrive/S3/Notion для автоматичного тегування та швидкого пошуку через пошукові API

Професійна розробка для вашого бізнесу

Створюємо сучасні веб-рішення та боти для бізнесу. Дізнайтеся, як ми можемо допомогти вам досягти цілей.