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

React 2026 Primitives & Compiler Upgrade Guide
Це розділ гайду React 2026 Primitives & Compiler Upgrade Guide. Фокус — useActionState: мутаційні флоу, optimistic UX, семантика черги та інтеграційні патерни.
Усі статті в цьому гайді
01
Огляд: React 2026 примітиви та мислення епохи компілятора
Велика картина: що змінилося, чому, і де кожен примітив реально використовується.
02
useActionState: глибоке занурення — мутації, optimistic UI та інтеграційні патерни
Практичний deep dive у чергу Actions, Transitions, optimistic UX і межу, де data layer все ще перемагає.
03
React <Activity />: зберігайте state, ставте Effects на паузу і рендерте у фоні
Реальні патерни, компроміси продуктивності та пастки для tabs/drawers/shell UI.
04
useEffectEvent: глибоке занурення — дизайн effects, підписки та аналітика
Межі effects, дружні до лінтера, без stale closures і зайвих реконектів.
Що ви отримаєте з цього 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.
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]
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>
);
}Progressive enhancement: action props, permalink і «працює до завантаження JS»
React підсвічує особливу можливість, коли useActionState використовується з Server Functions: фреймворки можуть показувати серверні відповіді й тримати форми інтерактивними раніше (progressive enhancement / до завершення hydration). permalink потрібен, щоб pre-hydration submit знав, куди навігуватися. [2]
У Next.js це Server Functions (Server Actions у контексті мутацій): async-функції на сервері, які викликаються мережею. Next підкреслює загортання у Transition і модель інтеграції з кешем (один roundtrip може повернути оновлений UI і нові дані). [6]
Передача dispatcher у <form action={dispatchAction}> автоматично загортає submit у Transition; React також описує progressive enhancement для Server Functions і permalink. [2]
Next.js: Server Actions інтегруються з кеш-архітектурою фреймворку; <form action> / formAction автоматично загортаються в startTransition, а виклики йдуть через POST. [6]
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]
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]
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]
Проста таблиця рішень (евристика, не догма):
| Потреба | 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 сяє
Де data layer перемагає
Інтеграційний патерн: useActionState як UI shell, TanStack Query як cache engine
Практичний гібрид: useActionState для success/error на рівні форми, а TanStack Query — для invalidation, щоб інші екрани відобразили зміни. У документації TanStack показує invalidateQueries як стандартний крок після мутації і зазначає, що Promise в onSuccess тримає мутацію «pending», поки invalidation не завершиться. [7]
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 поведінка форм
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 чудовий для UI-рівня (pending + result + errors). Data layers все ще володіють кешем, ретраями, інвалідацією, background refetch і синхронізацією між екранами. Якщо мутація впливає на кілька queries/routes, data layer (або кеш-модель фреймворку на кшталт Next.js) зазвичай правильний власник. [7][8][9][6]
Якщо ви викликаєте dispatcher вручну, він має бути всередині Transition (`startTransition`). Або передайте його як `action`/`formAction` — React загорне виклик у Transition за вас. Інакше React може попереджати, а `isPending` працюватиме некоректно. [2]
Так: React ставить dispatch-и в чергу і виконує їх послідовно, тож кожне виконання бачить попередній результат як `previousState`. Це прибирає частину out-of-order багів, але може створювати backpressure у спамних флоу. [2]
Використовуйте `useOptimistic` для миттєвого UI, скасовуйте pending роботу через `AbortController`, коли потрібен “latest intent wins”, або перегляньте, чи useActionState взагалі підходить для цього interaction. React у документації прямо описує ці escape hatches. [2][3]
Для очікуваних помилок (валідація, 4xx) повертайте error-state замість throw. Якщо action кидає exception, React може пропускати наступні queued dispatch-и. Throw залишайте для виняткових кейсів під error boundaries. [2]
`permalink` орієнтований на RSC-фреймворки з progressive enhancement. Якщо форма сабмітиться до завантаження JS, браузер може навігуватися на permalink-роут, не покладаючись на поточний URL. Фреймворки часто беруть це на себе, але це важливо, якщо ви хочете “працює до JS”. [2][6]
React 19 form Actions можуть автоматично скидати uncontrolled inputs після успіху. Також є `requestFormReset` для ручних reset-сценаріїв. Для draft/save-progress флоу варто свідомо обрати uncontrolled або controlled inputs і протестувати поведінку. [1]
Джерела
Ми додаємо лише ті джерела, які напряму підтверджують семантику, застереження та інтеграційні твердження в цьому розділі.
• 1. React v19 (official): Actions, useActionState, useOptimistic, useFormStatus, form reset + requestFormReset Читати джерело ↗
• 2. useActionState (official reference): queue semantics, Transitions requirement, permalink, cancellation, error handling caveats Читати джерело ↗
• 3. useOptimistic (official reference): optimistic state semantics, reducer pattern, re-running reducer on new base data Читати джерело ↗
• 4. useFormStatus (official reference): reading parent form status without prop drilling Читати джерело ↗
• 6. Next.js (official): Updating Data with Server Functions (Server Actions), automatic Transition for form action props, caching integration, POST behavior Читати джерело ↗
• 7. TanStack Query (official): Invalidations from Mutations (invalidateQueries + onSuccess promise semantics) Читати джерело ↗
• 8. SWR (official): Mutation & Revalidation (useSWRMutation + cache update behavior) Читати джерело ↗
• 9. Redux Toolkit (official): RTK Query Automated Re-fetching (cache tags invalidation model) Читати джерело ↗
• 13. PAS7 Studio (published chapter): React <Activity /> deep dive (series next chapter reference) Читати джерело ↗
Хочете впровадити Actions без регресій?
useActionState сильний, коли межі відповідальності чисті: UI володіє «рукостисканням» мутації, а система — коректністю спільних даних.
Якщо ви оновлюєтеся до React 19 (або вводите Server Actions, optimistic UX і cache-стратегію), PAS7 Studio може зробити аудит мутаційних флоу, визначити межі ownership і допомогти з поетапним rollout із guardrails.
useActionState: глибоке занурення — мутації, optimistic UI та інтеграційні патерни