PAS7 Studio
Natrag na sve članke

useActionState deep dive: mutation flowovi, optimistični UI, Action queue i kad data layer i dalje pobjeđuje (React 19+)

Praktični deep dive u React 19 useActionState: kako radi Action queue, zašto su Transitions ključne za isPending, progressive enhancement s permalinkom, integracija useOptimistic za instant UX, otkazivanje putem AbortControllera i okvir odlučivanja — kada TanStack Query / SWR / RTK Query i dalje trebaju biti vlasnici cachea, retry logike i invalidacije.

27. velj 2026.· 13 min čitanja· Tehnologija
Najbolje zaFrontend inženjeriTech leadoviTimovi koji rade upgrade na React 19
Studijska fotografija bilježnice: action queue, bilješke o optimističnom UI-ju i strelice React 19 formi

Ovo poglavlje je o korektnosti, UX-u i granicama ownershipa: što useActionState garantira, što ne — i kako ga koristiti bez reinvencije polupečenog data layera.

Mentalni model za Actions + useActionState (koji state drži i gdje se posao izvršava). [1][2]
Action queue: sekvencijalno izvođenje, zašto sprječava neke race bugove i kada postaje performance footgun. [2]
Zašto isPending ovisi o Transitions i dva sigurna načina kako triggerati actions. [2]
Progressive enhancement: permalink, server response prije završetka hydrationa i što framework obično odradi umjesto tebe. [2][6]
Optimistic UX s useOptimistic: instant UI, rollback i kako izbjeći stale optimistic state. [1][3]
Cancellation patterni s AbortController kad user može “spammati” actions. [2]
Okvir odlučivanja: kad TanStack Query / SWR / RTK Query i dalje trebaju imati ownership nad cacheom, retryjima, invalidacijom i sinkronizacijom. [7][8][9]
Sljedeće poglavlje: React <Activity /> deep dive (state retention + ponašanje Effectsa + rollout pitfallovi). [13]

Većina aplikacija ima dvije glavne odgovornosti: prikazati podatke i promijeniti podatke. Loading je često jednostavan; kompleksnost je u mutacijama — korektnost, UX, debugiranje i state strojevi.

React 19 “Actions” smjer cilja standardizirati mutation handshake: pending state, optimistic UI i error handling bez ad-hoc state mašina za svaku formu ili gumb. [1]

useActionState je tanak, ali bitan sloj: omota Action, sprema zadnji rezultat kao state i izlaže isPending kako bi UI ostao responzivan tijekom async rada. I daje ključnu semantiku: dispatchi se stavljaju u queue i izvršavaju sekvencijalno. [2]

Ako tim zapamti samo jednu stvar, neka to bude ovaj flow. Većina konfuzije nestane kad je ownership eksplicitan.

01

User intent ulazi kroz Action

Submit forme ili ručni poziv dispatchera pokreće mutation flow. Ako dispatcher zoveš ručno, mora biti unutar startTransition. [2]

02

React prati pending i queuea sljedeće dispatchove

Svaki novi dispatch čeka prethodni, a svako izvršavanje dobije zadnji dovršeni rezultat kao previousState. [2]

03

Action vraća UI state, ne cache

Koristi vraćeni state za poruke, field error-e, id-eve i lokalni post-submit UX. Ne pretvaraj ga potajno u shared storage.

04

Shared data ownership ostaje drugdje

Ako mutacija utječe na više ruta/queryja/consumera, delegiraj invalidaciju i sinkronizaciju framework cache modelu ili data layeru. [6][7][8][9]

Sažetak

Tretiraj useActionState kao tanku UI ljusku oko mutacije, ne kao system of record.

Signature je reducer-like: action prvo dobije previousState, zatim payload (za forme često FormData). Povratna vrijednost postaje sljedeći state. [2]

React može queueati više dispatcha i izvršavati ih sekvencijalno. Svako izvršavanje vidi prethodni rezultat kao previousState. To je feature korektnosti, ali znači i backpressure ako user triggera brže nego što se završava. [2]

Transitions nisu opcionalne. Ako dispatcher zoveš ručno, mora biti unutar Transitiona (startTransition). Ako ga proslijediš u action / formAction, React ga wrapa u Transition umjesto tebe. Inače isPending može biti pogrešan i React može upozoriti. [2]

Error handling je zamka: ako action baci exception, React može preskočiti sljedeće queued dispatchove. Za očekivane failove (validacija, 4xx) radije vrati error state nego throw. Throw ostavi za stvarno iznimne situacije za error boundary. [2]

Progressive enhancement je dio dizajna: permalink je za RSC-capable frameworke kako bi forma radila prije nego JS učita. Sa Server Functions, initial state mora biti serializable. [2]

Ovo su greške zbog kojih useActionState djeluje lošije nego što jest. API je mali; misuse je najčešći problem.

Zvati dispatcher izvan startTransition i onda se čuditi zašto je isPending varljiv. [2]

Throwati validacijske/očekivane 4xx greške umjesto vratiti strukturirani error state. [2]

Trpati velike payloadove u action state i slučajno pretvoriti UI hook u pseudo-cache.

Koristiti ga za high-frequency interakcije bez plana za queue backpressure, optimizam ili cancellation. [2][3]

Sažetak

Većina boli u rollout-u dolazi iz miješanja odgovornosti, ne iz hooka.

Najbrži “win odmah” su forme: server-side validation errori, field errori i pending state koji pouzdano disablea submit bez prop drillinga. Reactovi primjeri to ciljaju kao first-class case. [1][2][4]

Praktičan pattern: modeliraj rezultat kao discriminated union da UI nikad ne “pogađa” što je u stateu.

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

Jedan bitan detalj formi: React 19 form Actions mogu resetirati uncontrolled inpute nakon uspješnog submita, a React daje requestFormReset za ručne resete. Za “save draft” flowove obavezno testiraj uncontrolled vs controlled. [1]

Ako imaš component library (Button, SubmitButton, FormFooter), provlačenje isPending kroz layer-e je naporno. useFormStatus čita parent form status kao context i dizajniran je baš za to. [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 nije “pretvaraj se da je brzo”. To je “prikaži očekivani final state dok je request pending i imaj čist oporavak na fail”. useOptimistic u React 19 je zamišljen kao par Actionsima: optimistic state postoji tijekom Actiona, a nakon završetka/faila React se vraća na source-of-truth. [1][3]

Ključni trik iz doksa: s useOptimistic(items, reducer) React može ponovno pokrenuti reducer ako se base prop items promijeni dok je action pending. To smanjuje stale optimistic state bugove u kolaborativnim ili skoro real-time UI-jevima. [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>
  );
}

Action queue je često podcijenjen detalj. Sekvencijalni dispatch uklanja klasu race bugova (kasni response ne pregazi noviji intent). Ali može stvoriti backlog: click-spam serijalizira mrežne pozive i UI djeluje tromo. React eksplicitno preporučuje useOptimistic, cancellation ili drugačiji pristup za kompleksne slučajeve. [2][3]

Cancellation je koristan za “latest intent wins”, ali nije besplatan: abort mrežnog requesta ne vraća server mutaciju unatrag. Najsigurnije je kad je side effect idempotentan, sigurno ignorabilan ili retryable. [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 može otkazati pending Actions, ali abort requesta ne poništava server-side mutacije. [2]

Snimka zaslona sekcije queued-actions-and-cancellation

Čista granica: useActionState je odličan za UI-owned mutation handshake; data layeri su odlični za sistemsku korektnost podataka (cache, retry, invalidacija, sinkronizacija između ekrana).

React 19 daje bolji primitiv za mutacije, ali namjerno ne pokušava zamijeniti odgovornosti data libraryja. I dalje moraš odlučiti tko posjeduje caching i sinkronizaciju. [1][2]

Jednostavna tablica odluke (heuristika, ne dogma):

MD
| Potreba | useActionState | Data layer |
|---|---|---|
| Pending + field/server errori u formi | ✅ | ✅ |
| Jednostavna one-off mutacija s lokalnim UI updateom | ✅ | ✅ |
| Caching + dedupe + background refetch | ❌ | ✅ (SWR / TanStack Query) |
| Retry / backoff politike | ❌ | ✅ |
| Cache invalidacija preko više ekrana | ❌ | ✅ |
| Offline queue / persistence | ❌ | ✅ |
| Optimistic UI u malom scopeu | ✅ (s useOptimistic) | ✅ |
| Optimistic UI + shared cache reconciliation | ⚠️ | ✅ |

Ako mutacija utječe na više queryja ili ekrana, obično želiš da biblioteka koja modelira taj graf posjeduje invalidaciju i refetch. TanStack Query tretira invalidaciju kao first-class workflow; RTK Query radi isto kroz tagove; SWR ima mutation + revalidation primitive iz sličnih razloga. [7][9][8]

Gdje useActionState briljira

UI-owned mutation flowovi: forme, settings paneli, wizardi, “save” gumbi i komponente gdje result state treba lokalno (uz eventualni redirect). Manje boilerplatea, bolji UX defaulti. [1][2]

Gdje data layer pobjeđuje

Sistemska korektnost podataka: shared cache, refetch orkestracija, retry, dedupe, offline, cross-route konzistentnost. To su netrivijalne odgovornosti koje useActionState ne modelira. [7][8][9]

Čest hibrid

Koristi useActionState za UI handshake (pending + errors), ali invalidaciju/refetch delegiraj data layeru (ili Next cache modelu). Granice ostaju čiste. [6][7]

Comparison pointuseActionState pašeData layer paše
Form pending + field/server erroriDaDa
Jednostavna one-off mutacija s lokalnim UI updateomDaDa
Caching + dedupe + background refetchNeDa (SWR / TanStack Query)
Retry / backoff politikeNeDa
Cache invalidacija preko više ekranaNeDa
Offline queueovi / persistencijaNeDa
Optimistic UI za mali lokalni scopeDa (s useOptimistic)Da
Optimistic UI + shared cache reconciliationRizičnoDa

Praktični hibrid: useActionState za success/error na razini forme, a TanStack Query za invalidaciju kako bi drugi ekrani reflektirali promjenu. TanStack docs pokazuju invalidateQueries kao standardni korak i napominju da Promise u onSuccess drži mutaciju pending dok se invalidacija ne završi. [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: invalidacija nakon mutacija kroz onSuccess + invalidateQueries (Promise.all za više ključeva). [7]

Snimka zaslona sekcije integration-example-react-query

Većina regresija dolazi iz miješanja odgovornosti ili ignoriranja semantike: transitions, queueing, resetovi i veličina statea.

Praktična heuristika: u action state spremaj samo ono što UI treba nakon mutacije (errori, success, id-evi). Dugotrajni ili shared podaci trebaju biti na serveru, u framework cacheu ili u data layeru. [1][6][7]

ne pregovara se

Ručni dispatch mora biti unutar startTransition, inače React upozori i isPending neće raditi kako treba. action/formAction wrapa umjesto tebe. [2]

pazi spam pathove

Sekvencijalni dispatch sprječava neke race bugove, ali može serijalizirati interakcije u backlog. Za rapid-fire: useOptimistic, cancellation ili drugačiji model. [2][3]

drži malo

Action state tretiraj kao UI state (errori, poruke, id-evi), ne kao cache. Veliki objekti povećavaju cijenu rendera i otežavaju reconciliation.

testiraj uncontrolled

React 19 form Actions mogu resetirati uncontrolled inpute nakon uspjeha; requestFormReset postoji za ručne resete. Draft/progress flowovi trebaju svjesnu strategiju. [1]

Ako usvajaš useActionState kroz cijelu codebase, ove provjere sprječavaju najčešće UX i korektnost pitfallove.

  • Modeliraj action result state kao discriminated union (izbjegni nejasne stateove).

  • Za očekivane failove vrati error state umjesto throwa kako ne bi preskakao queued actions. [2]

  • Ručni dispatch mora biti u startTransition, inače je isPending varljiv. [2]

  • Za “spammy” interakcije odluči: sekvencijalna konzistentnost, cancellation (AbortController) ili optimistic reducer. [2][3]

  • Ako mutacija utječe na više ekrana, delegiraj invalidaciju/refetch data layeru (ili Next cache modelu). [7][9][6]

  • Za Server Functions: osiguraj da je initial state serializable i testiraj progressive enhancement (permalink / pre-hydration submit). [2][6]

  • Testiraj reset ponašanje uncontrolled inputa i odluči trebaš li controlled inputs ili eksplicitni reset. [1]

Zamjenjuje li useActionState TanStack Query / SWR / RTK Query?

Ne. useActionState je odličan za UI handshake (pending + result + errors). Data layeri i dalje posjeduju caching, retry, invalidaciju, background refetch i sinkronizaciju između ekrana. Ako mutacija utječe na više queryja/routes, data layer (ili framework cache model poput Next.js) je obično pravi owner. [7][8][9][6]

Zašto se `isPending` ne ponaša kako očekujem?

Ako dispatcher zoveš ručno, mora biti unutar Transitiona (`startTransition`). Alternativno, proslijedi ga kao `action`/`formAction` — React će ga wrapati u Transition umjesto tebe. Inače React može upozoriti i `isPending` može biti netočan. [2]

Jesu li Actions stvarno sekvencijalne? Što to donosi?

Da: React queuea dispatchove i izvršava ih sekvencijalno, pa svako izvršavanje vidi prethodni rezultat kao `previousState`. To sprječava dio out-of-order bugova, ali može stvoriti backpressure u “spammy” flowovima. [2]

Kako izbjeći backlog queuea kod brzih interakcija?

Koristi `useOptimistic` za instant UI, otkazi pending rad s `AbortController` kad želiš “latest intent wins”, ili razmisli o drugom modelu ako useActionState nije dobar fit. React docs eksplicitno navode te escape hatchove. [2][3]

Koji je siguran način za error handling u reducerAction?

Za očekivane failove (validacija, 4xx) vrati error state umjesto throwa. Ako action baci, React može preskočiti sljedeće queued dispatchove. Throw ostavi za stvarno iznimne slučajeve za error boundaries. [2]

Što je permalink i treba li mi?

`permalink` je za RSC-capable frameworke s progressive enhancementom. Ako se forma submit-a prije nego JS učita, browser može navigirati na permalink route umjesto da ovisi o trenutnom URL-u. Framework često odradi to umjesto tebe, ali je važno ako želiš “radi prije JS-a”. [2][6]

Zašto su mi se polja forme ispraznila nakon uspješnog submita?

React 19 form Actions mogu automatski resetirati uncontrolled inpute nakon uspjeha. React ima i `requestFormReset` za ručne resete. Za draft/save-progress flowove namjerno odaberi uncontrolled vs controlled i testiraj ponašanje. [1]

Uključujemo samo izvore koji direktno podupiru semantiku, upozorenja i integracijske tvrdnje korištene u ovom poglavlju.

Provjereno: 06. ožu 2026.Vrijedi za: React 19.xVrijedi za: Next.js App RouterVrijedi za: Server Functions / Server ActionsTestirano s: useActionStateTestirano s: useOptimisticTestirano s: useFormStatusTestirano s: TanStack Query v5

useActionState je moćan kad su granice čiste: UI posjeduje mutation handshake, a sustav posjeduje korektnost shared podataka.

Ako radiš upgrade na React 19 (ili uvodiš Server Actions, optimistic UX i cache strategiju), PAS7 Studio može auditirati tvoje mutation flowove, postaviti granice ownershipa i isporučiti inkrementalni rollout plan s guardrailsima.

Vi ste ovdje02/04

Dubinski vodi? za useActionState: mutation flows, optimistic UI i integration patterns

Povezani članci

growth

AI SEO / GEO u 2026: vaši sljedeći kupci nisu ljudi — nego agenti

Pretraživanje se pomiče s klikova na odgovore. Botovi i AI agenti pretražuju, citiraju, preporučuju i sve češće kupuju. Saznajte što znači AI SEO / GEO, zašto klasični SEO više nije dovoljan i kako PAS7 Studio pomaže brendovima pobijediti u agentičkom webu.

blogs

Najmoćniji Apple čip? M5 Pro i M5 Max ruše rekorde

Analiza Apple M5 Pro i M5 Max čipova u ožujku 2026. Objašnjavamo zašto se ovi čipovi mogu smatrati najjačim profesionalnim laptop SoC-ovima koje je Apple dosad napravio, kako izgledaju protiv M4 Pro, M4 Max, M1 Pro, M1 Max i što pokazuju u usporedbi s aktualnim Intel i AMD konkurentima.

telegram-media-saver

Automatsko označavanje i pretraga spremljenih linkova

Integracija s GDrive/S3/Notion za automatsko označavanje i brzu pretragu putem search API-ja

services

Razvoj botova i usluge automatizacije

Profesionalni razvoj Telegram botova i automatizacija poslovnih procesa: chatbotovi, AI asistenti, CRM integracije, automatizacija radnih tijekova.

Profesionalni razvoj za vaše poslovanje

Kreiramo moderne web rješenja i botove za poduzeća. Saznajte kako vam možemo pomoći u postizanju ciljeva.