React 19 Actions: I Explained 3 Hooks Without Ever Explaining What an Action Is

작성자

카테고리:

← 피드로
DEV Community · Shubhra Pokhariya · 2026-09-01 개발(SW)

Three parts into this series, and if you’d asked me to define the word sitting underneath every single hook I’d taught you, I’m not sure I could have done it cleanly. That’s not false modesty. Go back and read Parts 1 through 3. I used the word “Action” constantly. I never once stopped to say what one actually is.

Part 1 opened with three state variables I’d hand-rolled for years, a result, a pending flag, an error, and showed how useActionState collapses all three into one call. Part 2 took a comment box and made it feel instant before the server had even replied. Part 3 let a submit button three components away read a form’s pending state with zero props passed down. Three different problems, three different hooks, and every single one of them was quietly leaning on the same mechanism without me ever naming it out loud.

That’s the gap this post closes. Not a new bug this time. The concept the last three bugs were all symptoms of.

The word I kept using and never defined

Here’s the actual definition, straight from React’s own docs, not a paraphrase I’m softening for effect. A function called inside startTransition is an Action. That’s it. That’s the whole qualifying test.

async function submitFeedback(previousState, formData) {
  const message = formData.get("message");
  await saveFeedback(message);
  return { sent: true };
}

Enter fullscreen mode Exit fullscreen mode

That function above could sit in your codebase forever and never once behave like an Action. What flips the switch has nothing to do with anything written inside it. It’s entirely about the doorway it walks through. Four doorways do this: a direct startTransition call, useTransition‘s version of the same call, a form’s action prop taking a function instead of a string, or useActionState. Walk a function through any of them and it stops being a plain async function you’re tracking by hand. What React takes over automatically is the Transition itself, the pending tracking underneath it. What it does not universally hand you is the function’s return value. useTransition gives you isPending and nothing else, a raw startTransition call doesn’t return anything at all. useActionState is the one doorway built specifically to catch that return value and hold onto it as state, which is exactly why isPending and a real result showed up together back in Part 1 and nowhere else in this series. More on the ordering side of this once useTransition shows up properly below.

Once that clicked for me, Parts 1 through 3 stopped looking like three separate hooks and started looking like three different windows onto the same room. useFormStatus reads the pending status of the nearest parent form, and now you know why, that pending status only exists in the first place because something wrapped the submission in a Transition. useOptimistic is the odd one out. It isn’t a door into becoming an Action at all. It’s a setter you call from inside one that’s already running. Call it outside a Transition and it just won’t behave the way Part 2 showed you.

Why any of this needed inventing

Part 1’s opening line still holds up: every form needed the same three pieces of state, wired by hand, every time. That pattern wasn’t unique to forms either. A delete button, a follow toggle, a checkout step, all needed the identical scaffolding rebuilt from scratch. React 19 added the ability to use async functions inside transitions in the first place, which is the foundation everything else in this series stands on. What you build on that foundation, whether you get a tracked result back or just a pending flag, depends on which of the four doorways you actually walk through. useActionState is the one that goes furthest, catching the return value as state. The others give you less by design.

<form action={fn}>, and the three things nobody tells you change

Before Actions existed, a form’s only real path to running code was onSubmit, and that path came with requirements you had to remember every time. Call preventDefault, manually construct FormData from the event target, manage loading and error state by hand.

function OldForm() {
  function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.target);
    // run the request, manage loading and error state yourself
  }
  return <form onSubmit={handleSubmit}>{/* ... */}</form>;
}

Enter fullscreen mode Exit fullscreen mode

Swap onSubmit for a function passed straight to action and the mental model shifts more than the syntax suggests. Nothing calls preventDefault, not because you forgot it, but because React already knows this is an Action before the form ever fires, so the browser’s default full-page reload was never going to happen in the first place. The function also stops receiving an event. It receives FormData directly, already built from every named field on the page, which is one less step you used to write by hand every time. And the request method itself stops being something you configure at all. A function passed to action always submits as POST, whatever you write in a method attribute sitting right next to it. That last one is the exact fact Part 3 ran into from the useFormStatus side, back when method kept coming back 'post' no matter what I expected walking in. It was never reading an attribute. It was reading the Action underneath it.

onSubmit still earns its keep for one job specifically. Anything that has to stop a submission before it starts, checking two password fields match, belongs there. By the time code inside the Action itself runs, the submission has already begun, so nothing in there can be the thing that prevents it.

Sync Actions exist. They’re just not interesting

An Action doesn’t have to be async. A plain synchronous function handed to the same four doors still counts, React still manages its lifecycle, but the pending window is usually too short to build UI around.

function resetSearch(previousState, formData) {
  return { query: "" };
}

Enter fullscreen mode Exit fullscreen mode

The instant await shows up inside that function, everything from Parts 1 through 3 becomes worth having. React 18 required startTransition‘s callback to be synchronous. React 19 dropped that restriction, and that single change is what let useActionState, <form action={fn}>, and useTransition all accept async functions directly instead of you managing the async boundary by hand.

Client Actions and Server Functions are not the same word twice

Everything in this series so far has been a client Action, code that runs in the browser whether or not it talks to a server underneath. That version works identically in any React 19 setup, no framework required.

Server Functions are a related but genuinely separate idea, and they only exist in frameworks with Server Component support, Next.js being the obvious one. React’s own docs are specific about the relationship here, and it’s worth getting right instead of using the two terms interchangeably like I nearly did drafting this: a Server Function only becomes a Server Action once it’s passed to an action prop or called from inside an Action. Not every Server Function is a Server Action. Every Server Action used this way is a Server Function.

"use server";

export async function submitFeedback(formData) {
  const message = formData.get("message");
  await db.feedback.create({ message });
}

Enter fullscreen mode Exit fullscreen mode

That single formData argument is the shape a Server Function receives when it’s handed directly to a form’s action prop. It’s worth flagging because it’s not interchangeable with useActionState as-is, which always calls its function with (previousState, formData), the two-argument shape Part 1 introduced. Reusing server logic through useActionState means writing for that shape from the start, not dropping in a single-argument version and hoping React adapts it for you. It won’t.

The fourth door: useTransition

useTransition hasn’t come up by name once in this series, and the mechanism it exposes has been running underneath every single example anyway. Calling it gives you exactly two things back, an isPending flag and a startTransition function.

Every checkout example so far ran inside a <form>, which wraps its Action in a Transition automatically. useTransition is for when there’s no form to lean on. Picture a wishlist heart icon sitting on a product card, no form anywhere near it, just a click handler that needs to hit the server and report whether it’s pending.

import { useTransition, useState } from "react";

function WishlistButton({ productId, isSaved, toggleWishlist }) {
  const [isPending, startTransition] = useTransition();
  const [saved, setSaved] = useState(isSaved);

  function handleClick() {
    startTransition(async () => {
      setSaved(!saved);
      const result = await toggleWishlist(productId);
      startTransition(() => {
        setSaved(result.saved);
      });
    });
  }

  return (
    <button onClick={handleClick} disabled={isPending} aria-pressed={saved}>
      {saved ? "♥ Saved" : "♡ Save"}
    </button>
  );
}

Enter fullscreen mode Exit fullscreen mode

Notice that second, nested startTransition wrapping setSaved after the await. If that looks familiar, it should, it’s the exact same shape as onConfirmed getting wrapped in its own startTransition back in Part 2’s comment box. That’s not two separate patterns you happened to see twice. React only automatically tracks synchronous work as part of a Transition. Anything after an await needs its own startTransition call to still count, whether you’re inside a comment form or a heart icon with no form in sight.

Notice too that the wishlist button above reaches for plain useState, not useOptimistic. That’s not an oversight. This example exists to isolate what useTransition gives you entirely on its own, before any of the other three hooks get involved. In a real app you’d almost certainly reach for useOptimistic here instead, the exact instant-feedback job it was built for back in Part 2. Showing it with bare useState first is the only way to actually see what useTransition alone is doing.

Here’s the part that’s easy to miss and expensive to find out the hard way. Click that heart, unclick it, click it again fast, and a raw useTransition call gives you no guarantee about which result lands last. React says so directly: Actions inside a Transition don’t guarantee execution order on their own. What’s easy to assume, and what I nearly wrote into this section before checking, is that <form action={fn}> is just as exposed to that same problem as a bare useTransition call. It isn’t. React’s own docs group <form> actions together with useActionState as the two built-in ways that already handle ordering for you. It’s raw useTransition, used on its own with nothing else wrapping it, that gets no such guarantee, the wishlist button above included. If you’ve been assuming that guarantee comes from Actions in general, it doesn’t. It’s specifically what useActionState and <form> actions add on top of the raw mechanism.

Where the four actually sit next to each other

You need to… Reach for Own a form’s result and pending state in one place useActionState Show the finished UI before the server has confirmed it useOptimistic Read a form’s pending state from a component that isn’t managing it useFormStatus Run an Action outside a form and track its pending state useTransition

They were never four competing answers to “which hook should I learn.” They’re four answers to four different questions that happen to share one mechanism underneath. The checkout form from Part 3 already proved it by combining two of them across four separate components, useActionState owning the submission, useFormStatus letting PlaceOrderButton read pending state with nothing passed down. Here’s that same order form with a third hook layered on, a promo code that discounts the total the moment the form is submitted with it, before the server has confirmed anything.

import { useActionState, useOptimistic } from "react";
import { useFormStatus } from "react-dom";

async function placeOrder(previousState, formData) {
  const result = await submitOrder(formData);
  return { orderId: result.id, error: null };
}

function PlaceOrderButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? "Placing order..." : "Place order"}
    </button>
  );
}

function OrderForm({ cartTotal }) {
  const [state, formAction] = useActionState(placeOrder, {
    orderId: null,
    error: null,
  });

  const [displayTotal, applyDiscount] = useOptimistic(
    cartTotal,
    (current, discount) => current - discount,
  );

  function handleSubmit(formData) {
    if (formData.get("promoCode") === "SAVE10") {
      applyDiscount(10);
    }
    formAction(formData);
  }

  return (
    <form action={handleSubmit}>
      <p>Total: ${displayTotal.toFixed(2)}</p>
      <input name="promoCode" placeholder="Promo code" />
      <PaymentFields />
      <PlaceOrderButton />
    </form>
  );
}

Enter fullscreen mode Exit fullscreen mode

useActionState still owns the real submission the same way it did in Part 3. useOptimistic shows the discounted total the moment the form submits with SAVE10 in the field, no waiting on submitOrder to confirm anything first, and it falls back on its own to whatever cartTotal currently is once the Transition settles. That fallback detail matters more than it looks. This trimmed-down version never actually updates cartTotal after submitOrder resolves, so the discount would vanish the instant the Action finishes, same trap Part 2 walked through with onConfirmed. A real version needs the parent to fold the confirmed, discounted total back into cartTotal once the order actually goes through, or the optimistic value has nothing real to settle into. useFormStatus still lets PlaceOrderButton disable itself with nothing passed down from OrderForm. Three hooks, one form, none of them stepping on the other’s job.

The mistake I nearly baked into this whole series

Here’s the trap I think this series set without meaning to. Teach one hook per post, three posts in a row, and a careful reader could walk away thinking the real task was picking a favorite, useActionState or useOptimistic or useFormStatus, as if only one of them belongs in a given form. That was never true. Composition was always the point. A real form rarely reaches for exactly one of these. It reaches for whichever two or three actually solve the piece of the problem sitting in front of it, and none of them are competing for the same job.

I built this series backwards on purpose, honestly, three concrete bugs before the concept underneath them, because I think a concept sticks harder once you’ve already felt the shape of the problem it solves. But it only works if the concept actually shows up eventually. This is that part.

If you want the deeper cut, sync versus async Actions in more detail, the full Server Functions section, and a proper FAQ, I wrote the complete version on my site: React 19 Actions Explained.

If you’d rather test what actually stuck first, I also built a 15-question quiz covering the same four APIs: React 19 Actions Quiz.

So here’s the real question, the one I’d genuinely like an answer to. Which of these four have you already been using without knowing you were touching an Action? A <form action={fn}> you wired up because it looked simpler than onSubmit, a startTransition call you copied from somewhere without reading what it does, a button that felt laggy until you wrapped it in useTransition and never asked why that fixed it. Drop it below. I’m curious how many of us have been using this mechanism for months without a name for it.

원문에서 계속 ↗