PAS7 Studio

Tehnologija

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.· 18 min čitanja
Studijska fotografija bilježnice: action queue, bilješke o optimističnom UI-ju i strelice React 19 formi
Vodič / SerijaČlanak serije

React 2026 Primitives & Compiler Upgrade Guide

Ovo je poglavlje u React 2026 Primitives & Compiler Upgrade Guide. Fokus: useActionState — mutation flowovi, optimistic UX, semantika queuea i integracijski patterni.

Što dobivaš iz ovog deep divea

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]

Zašto useActionState postoji: učiniti mutation flowove dosadnima (na dobar način)

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]

API semantika koja je bitna u produkciji

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]

Baseline pattern: submit forme s tipiziranim result stateom

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]

Design system trik: useFormStatus uklanja prop-drilling pending statea

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 bez samozavaravanja: useOptimistic + realna rollback priča

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

Queued actions: kad sekvencijalna konzistentnost postane backpressure

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

Kad useActionState smanjuje boilerplate — i kad data layer i dalje posjeduje problem

Č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]

Integracijski pattern: useActionState kao UI shell, TanStack Query kao cache engine

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

Bilješke o performansama i korektnosti (stvari koje kasnije grizu)

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]

Transition disciplina

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]

Queue backpressure

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]

Veličina payload-a u stateu

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.

Reset ponašanje formi

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]

Rollout checklist koji možeš dati timu

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]

ČPP

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]

Izvori

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

Želiš uvesti Actions bez regresija?

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

useActionState deep dive: mutation flowovi, optimistični UI i integracijski patterni

Povezani članci

growthFebruary 15, 2026

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.

Čitati →
telegram-media-saverJanuary 8, 2025

Automatsko označavanje i pretraga spremljenih linkova

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

Čitati →
servicesJanuary 2, 2025

Razvoj botova i usluge automatizacije

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

Čitati →
backend-engineeringFebruary 15, 2026

Bun vs Node.js u 2026: zašto Bun djeluje brže (i kako provjeriti aplikaciju prije migracije)

Bun je brži all-in-one JavaScript toolkit: runtime, package manager, bundler i test runner. Donosimo što je stvarno (uz benchmarke), što se može pokvariti i kako dobiti besplatni readiness audit pomoću @pas7-studio/bun-ready.

Čitati →

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.