For most of React's run, a data mutation meant the same ritual. You kept a value in useState, fired a fetch from inside useEffect or an event handler, managed a loading flag by hand, tracked an error string, and rolled the state back yourself when the request failed. Every form repeated it with small variations. It worked, and it was a tax you paid on every single write.

React 19 replaced that stack with an action model. Mutations become async functions, and the framework owns the pending state, the error handling, and the optimistic update for you. The primitives are Server Actions, useOptimistic, useActionState, and useFormStatus, and in Next.js 16 they compose into a mutation pattern that is shorter and harder to get wrong than what came before.1 React is at 19.2.8 as of this writing, so the model is stable and shipping in production apps.2

This article is about the shape of that model: what each piece does, how they compose, and where you should keep the old approach. It is a companion to our earlier look at scalable React architecture, which covered rendering and state management; this one is specifically about the write path.

The problem the action model solves

The pain was never the network call. It was the state choreography around it. A single mutation needed four separate pieces of state that had to stay in sync: the data, a pending flag, an error, and the rollback path. Three different UI elements might each read a different one.

React 19's answer is to treat the mutation as a single Action, a function run inside a transition. An async transition immediately sets its pending state to true, runs the request, then flips pending back off when the work commits.1 The useTransition hook exposes that pending flag, and it is the foundation everything else sits on.

The old UpdateName form needed useState for the name, useState for the error, useState for pending, and a handler that set each one at the right moment. With useActionState, the whole thing collapses into one call.1

React 19's action model collapses the manual mutation stack: before, six hand-synced pieces of state per write; after, four primitives the framework manages for you
React 19's action model collapses the manual mutation stack: before, six hand-synced pieces of state per write; after, four primitives the framework manages for you

useActionState: state and pending in one hook

useActionState is the workhorse. It takes a reducer-style action, an initial state, and returns three things: the current state, a dispatch function you hand to a form or button, and an isPending flag.3

const [state, formAction, isPending] = useActionState(
  async (previousState, formData) => {
    const error = await updateName(formData.get('name'))
    return error ?? null
  },
  null
)

The action receives the previous state as its first argument and the submitted FormData as its second, and whatever it returns becomes the new state. That is the whole error-handling story. A failed write returns an error object, the component reads it from state, and there is no separate useState for errors at all.3

Two details matter. First, isPending starts true the moment you dispatch and stays true until the action resolves, which replaces your hand-rolled loading flag.3 Second, the dispatch function is stable, so you can pass it straight to a form's action prop or a button's formAction prop without wrapping it. React queues and executes multiple dispatches sequentially, each one receiving the result of the previous, which is how you keep rapid submissions from racing.3

useFormStatus: the submit button that knows

There is a wrinkle with useActionState: the isPending flag lives in the component that owns the hook, but the submit button is usually a child component, often a shared design-system button. Threading a pending prop down through every form would recreate the coupling the model removes.

useFormStatus solves it with form context. Call it inside a component that renders as a descendant of the form, and it returns the nearest ancestor form's pending state, its FormData, its method, and its action. No prop drilling.4

Because useFormStatus reads the nearest ancestor form, it must be called from a component nested inside that form. Call it from the same component that renders the form and pending is always false, a gotcha that trips up nearly everyone the first time.4

useOptimistic: show the result before the server agrees

The pending flag handles the waiting. useOptimistic handles the pretending. It lets you render the state you expect once the action succeeds, immediately, and lets React roll it back for free if the request fails.5

const [optimisticMessages, addOptimisticMessage] = useOptimistic(
  messages,
  (state, newMessage) => [...state, { text: newMessage, pending: true }]
)

Here messages is the server-authoritative state. useOptimistic returns the optimistic view plus a setter you call inside the Action. When you call the setter, React immediately re-renders with the optimistic value. When the action completes and the real state updates, the optimistic and real values converge in the same render, with no extra pass to clear anything.5

The clean parts are what you don't write. If the action throws, React reverts to the last real state automatically. If the user clicks Like on three posts before any response lands, React composes the optimistic updates in order and rolls back only the one that failed.6

There is a rule worth internalizing: the setter must be called from inside an Action, meaning inside a transition or an action prop. Call it outside one and React warns and the optimistic state only renders briefly. Use a relative updater function rather than hardcoded values so rapid changes compute from the current state, not a stale snapshot.5

Server Actions: the mutation that crosses the wire

So far these are client-side hooks. The missing piece is where the mutation actually runs. In the old model you wrote a route handler or an API route and called it with fetch. Server Actions remove that layer. A Server Action is an async function marked with the 'use server' directive that runs on the server, and the client calls it directly, no route handler and no fetch wrapper in between.7

// app/actions.ts
'use server'

export async function updateName(formData) {
  const session = await auth()
  if (!session?.user) throw new Error('Unauthorized')
  const name = formData.get('name')
  // validate, persist, revalidate the cache
}

The 'use server' directive at the top of the file marks every export as a Server Action. During the build, Next.js assigns each one a unique action ID, a hash of its module path and export name, and keeps a registry mapping IDs to implementations. When the client calls one, the runtime serializes the arguments, POSTs them to the framework's action endpoint with the action ID, the server looks it up, runs the function, and returns a streaming React Server Component payload. That payload is why a mutation can update server-rendered content in the same roundtrip.6

A Server Action is one POST: the form passes the action, React POSTs it with a generated action ID, the server looks the ID up in its registry, authorizes, runs the function, and returns a streaming RSC payload that updates the UI and revalidates the cache
A Server Action is one POST: the form passes the action, React POSTs it with a generated action ID, the server looks the ID up in its registry, authorizes, runs the function, and returns a streaming RSC payload that updates the UI and revalidates the cache

Two consequences follow from this design, and both matter.

First, progressive enhancement works by default. When a Server Action is passed to a form's action prop, the browser can submit it without JavaScript as a standard POST, and the server returns a fully rendered page. When JavaScript is available, React intercepts the submission and handles it as a streaming RSC update instead of a page reload. Same code path, both environments.6 This is a real upgrade over SPA-only mutation patterns that required the client bundle to load before anything could write.

Second, the security model changes. Server Actions are reachable via direct POST requests, not just through your UI. Anyone can craft a request to the action endpoint. So every Server Action must re-verify authentication and authorization on the server, never trusting that the client only calls it when it should. The Next.js guidance is explicit: verify the session, and verify the user owns the resource before mutating it. Treat every argument as user-controllable, because it is.7

There is also a sequencing detail. Next.js dispatches and awaits Server Actions one at a time from the client, so they are designed for mutations, not for parallel reads. For parallel data fetching, keep it in Server Components or inside a single action. A fan-out of independent writes belongs in one action or a route handler.7

How the pieces compose

The four primitives are designed to be used together, and together they replace the entire manual stack. A cart, a comment form, a like button, all follow the same shape: a Server Action does the write, useActionState threads the response into state and exposes isPending, useOptimistic shows the result instantly, and useFormStatus disables the button. The four lines replace a Zustand store with a loading flag, an error field, an optimistic field, a fetch call in an effect, and a try/catch rollback.6

In Next.js, the action is also the cache invalidation point. A Server Action that changes data calls revalidatePath or revalidateTag (or the newer updateTag) to clear the affected cache, then optionally redirect. The mutation and the cache refresh live in the same function, which is the natural place for them. The refresh function from next/cache refreshes the client router without revalidating tagged data, which is a lighter touch when you only need the current page to reflect new state.7

Where the action model does not fit

The action model is not a replacement for all state. It is specifically superior for server mutation flows: form submissions, database writes, API mutations. For client-only state that never touches the server, like drag-and-drop position, multi-step wizard progress, or WebSocket-driven collaborative state, the action model is the wrong tool. That state stays in useReducer, Zustand, or XState.6

The line is clearer than it sounds. If the state is authoritative on the server and the client is mirroring it, use Actions. If the state lives entirely in the client and the server is never the source of truth, use what you were using. Real-time collaborative editing, which needs conflict resolution across peers, is a classic case for a dedicated store with explicit merge logic.6

The migration path

You do not have to rip out your state management. Migrate one mutation flow at a time. Pick a form or a write-heavy component, replace its useState + fetch handler with a Server Action plus useActionState, and watch the error and loading state disappear. Keep the existing store for everything else until the pattern is comfortable.6

Three pitfalls will show up in practice. First, useFormStatus must live inside the form, not beside it, or pending never turns true. Second, optimistic setters must be called inside Actions, and must compute from current state, or they go stale under rapid input. Third, Server Action arguments are user-controlled, so authorize on the server in every action, no exceptions. The framework handles the CSRF token automatically, but not authorization, and it will not remind you.67

For most production Next.js apps on React 19, this is a clear win: less boilerplate on the write path, progressive enhancement for free, and optimistic UI that rolls back without a manual handler. The rendering side of React got the attention in 2024 and 2025. The mutation side is the quieter part of the same change, and it is the one that touches how your app writes data.

Sources

  1. React Team. React v19. react.dev 2 3

  2. npm registry version check, react latest. registry.npmjs.org

  3. React Team. useActionState. react.dev 2 3 4

  4. React Team. useFormStatus. react.dev 2

  5. React Team. useOptimistic. react.dev 2 3

  6. Sam Cheek. React 19: useOptimistic, use(), Server Actions Explained. samcheek.com 2 3 4 5 6 7 8

  7. Next.js Team. Mutating Data (Server Actions). nextjs.org 2 3 4 5