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 мають залишатися власниками кешу, ретраїв та інвалідації.

Студійне фото блокнота: черга action, нотатки про optimistic UI та стрілки React 19 форм
Гайд / СеріяСтаття серії

React 2026 Primitives & Compiler Upgrade Guide

Це розділ гайду React 2026 Primitives & Compiler Upgrade Guide. Фокус — useActionState: мутаційні флоу, optimistic UX, семантика черги та інтеграційні патерни.

Що ви отримаєте з цього deep dive

Цей розділ про коректність, 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]

Навіщо існує useActionState: зробити мутаційні флоу знову «нудними»

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

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

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

Семантика API, яка реально важлива в проді

Сигнатура 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]

Базовий патерн: submit форми з типізованим result state

Найшвидший виграш — форми: серверні помилки, помилки полів і 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]

Трюк для дизайн-системи: useFormStatus прибирає prop-drilling pending-стану

Якщо у вас є компонентна бібліотека (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 + реальна історія rollback

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>
  );
}

Queued actions: коли послідовна коректність перетворюється на backpressure

Черга 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 зменшує boilerplate — і коли data layer все ще володіє проблемою

Чиста межа така: 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]

Інтеграційний патерн: useActionState як UI shell, TanStack Query як cache engine

Практичний гібрид: 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]

Дисципліна Transition

необхідна

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

Backpressure черги

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

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

Розмір payload у state

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

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

Reset поведінка форм

тестуйте 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]

Джерела

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

Хочете впровадити Actions без регресій?

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

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

Ви тут02/04

useActionState: глибоке занурення — мутації, optimistic UI та інтеграційні патерни

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

growth15 лютого 2026 р.

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

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

Читати →
telegram-media-saver8 січня 2025 р.

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

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

Читати →
services2 січня 2025 р.

Розробка Telegram-ботів та автоматизація

Професійна розробка Telegram-ботів та автоматизація бізнес-процесів: чат-боти, AI-асистенти, інтеграції з CRM та автоматизація процесів.

Читати →
backend-engineering15 лютого 2026 р.

Bun vs Node.js у 2026: чому Bun відчувається швидшим (і як перевірити застосунок перед міграцією)

Bun — це швидший all-in-one JavaScript toolkit: runtime, пакетний менеджер, бандлер і тест-раннер. Розбираємо, що реально дає приріст (з бенчмарками), що може зламатися, і як отримати безкоштовний readiness-аудит через @pas7-studio/bun-ready.

Читати →

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

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