Skip to content

    Frontend System Design

    Server Actions and Server-Driven Mutations

    Learn how React 19 Actions, form actions, useActionState and useOptimistic fit together, and how a Server Function turns a form submit into a server mutation.

    By Published 14 min read

    • frontend-system-design
    • react-19
    • server-actions
    • forms
    • mutations
    Share

    Standard lessonadvancedReact labFrontend System Design

    Before this: React Server Components

    What you'll learn: By the end of this lesson you will be able to explain what a React Action is, wire a <form action> to a function and read its pending state, returned errors and thrown errors, show an optimistic result that reverts or converges by itself, predict how queued Actions behave, and say what changes when the function runs on the server (a Server Function, called a Server Action when used in a form or mutation). You will also know which parts this lesson ran and which it only describes from documentation.

    Think about a restaurant order slip. You hand it to the waiter and want to know three things: was it taken, did the kitchen accept it, and what happens if they refuse. A form that saves data asks the same three questions: is it pending, did it succeed, and what did it say when it failed. For years every team answered them with its own useState flags and onSubmit handlers. React 19 gives the slip a standard shape: the Action.

    The running example is a product page with a back-in-stock sign-up box and a quantity stepper (the lab uses a sign-up form, a task list and a counter as stand-ins). The interesting part is not the fields but what happens between pressing the button and the answer arriving.

    This lesson covers React 19 at the time of writing (19.3 is the latest version on react.dev's versions page; the site hosting this course runs React 18.3). Running functions on the server needs a framework with React Server Components; the lesson uses Next.js's documentation and says where nothing was run.

    The core idea

    Hold on to four statements.

    1. An Action is a function you pass to a transition. react.dev: "The function passed to startTransition is called the 'Action'." It may be async, and React 19 tracks the whole async function, not only the synchronous start of it.
    2. A form can take a function as its action. React calls it with a FormData object, runs it inside a Transition, tells child components that the form is pending, sends a thrown error to the nearest Error Boundary, and resets the form's uncontrolled fields when the function succeeds.
    3. Two hooks sit on top of Actions. useActionState keeps the result of the last Action and queues Actions in order. useOptimistic shows a temporary value while an Action runs and then drops it.
    4. A Server Function is an Action whose body runs on the server. Marked with 'use server', it is called through a network request that carries serialised arguments. It is a public entry point, so it must check who is calling and what they sent.

    Everything below is a consequence of those four.

    What an Action gives you

    With a function action, the pending flag, the error and the reset are React's job. Figure 1 shows one submit.

    Figure 1. One form submit, step by step

    sequenceDiagram accTitle: Life of one form Action accDescr: The user submits, React starts a Transition and calls the action with FormData, the pending state is true while the request is open, then the returned state or the thrown error arrives and the form resets only on success. participant U as User participant R as React participant A as Action function participant S as Server U->>R: submit the form R->>A: call action(FormData) inside a Transition Note over R: useFormStatus pending is true
    useActionState isPending is true A->>S: request S-->>A: reply alt returned value A-->>R: next state (even an error message) Note over R: uncontrolled fields reset else thrown error A-->>R: throws Note over R: nearest Error Boundary renders end

    How to read this: React owns the pending flag and the reset. A reply that is an error message you return counts as success for the form; only a throw counts as failure.

    The reset applies to uncontrolled fields (inputs with a name and no value prop). To keep values after a failed submit, return them in the state and pass them back as defaultValue. (With a function action the HTTP method is always POST; the method prop is ignored.)

    The minimal example: a sign-up form

    This excerpt of the lab code is the smallest useful form.

    export function makeSignupAction(api: ControlledApi<{ email: string }, SignupReply>) {
      return async function signupAction(_previous: SignupState, formData: FormData): Promise<SignupState> {
        const email = String(formData.get("email") ?? "").trim();
        if (!email.includes("@")) {
          return { status: "error", message: "Enter a valid email address.", email };
        }
        const reply = await api.call({ email });
        if (!reply.ok) return { status: "error", message: reply.reason, email };
        return { status: "ok", email };
      };
    }
    
    export function SignupForm({ api }: { api: ControlledApi<{ email: string }, SignupReply> }) {
      const [state, formAction, isPending] = useActionState(makeSignupAction(api), { status: "idle" } as SignupState);
      return (
        <form action={formAction} aria-label="Sign up">
          <label>
            Email
            <input name="email" type="text" />
          </label>
          <SubmitButton label="Join" />
          {state.status === "error" ? <p role="alert">{state.message}</p> : null}
          {state.status === "ok" ? <p role="status">Welcome, {state.email}</p> : null}
        </form>
      );
    }

    useActionState(fn, initialState) returns [state, formAction, isPending]. React calls fn with the previous state first and the FormData second: react.dev warns that the form data "is therefore its second argument instead of its first".

    The button's pending look comes from a different hook, in a child component:

    export function SubmitButton({ label }: { label: string }) {
      const { pending } = useFormStatus();
      return (
        <button type="submit" disabled={pending}>
          {pending ? "Saving..." : label}
        </button>
      );
    }

    useFormStatus reads the status of the parent form, "as if the form was a Context provider". It must be rendered inside the <form>. Called in the component that renders the form, it never reports pending. The lab includes that mistake on purpose and its test shows pending staying false while the request is open.

    Returned errors and thrown errors

    A bad email address and an "already registered" reply are expected failures: the action returns them as state and the component renders them. A request that rejects is unexpected: the action lets it throw. A thrown error goes to the nearest Error Boundary, replacing the form, and with useActionState it also cancels the Actions queued behind it. A returned error leaves the form in place. Return what the user can fix; throw what only a developer can fix.

    Optimistic results with useOptimistic

    useOptimistic shows the expected result while the reply is on its way. Its value is "equal to value unless an Action is pending", so it disappears on its own when the Action ends.

    const [shown, addOptimistic] = useOptimistic(tasks, (current: readonly Task[], title: string): readonly Task[] => [
      ...current,
      { id: `pending:${title}`, title, pending: true },
    ]);
    
    async function addTaskAction(formData: FormData) {
      const title = String(formData.get("title") ?? "").trim();
      if (title === "") return;
      setError(null);
      addOptimistic(title);
      try {
        const saved = await api.call({ title });
        startTransition(() => setTasks((list) => [...list, saved]));
      } catch (caught) {
        startTransition(() => setError((caught as Error).message));
      }
    }

    Figure 2. The life of an optimistic value

    stateDiagram-v2 accTitle: Optimistic state lifecycle accDescr: With no Action pending the list shows the real value. When an Action starts and the setter is called the list shows the optimistic value. When the Action ends the list shows the real value again, which is the saved row on success and the old list on failure. [*] --> Real Real --> Optimistic: Action starts, set called Optimistic --> Real: Action ends, real value is new on success Optimistic --> Real: Action throws or fails, real value unchanged

    How to read this: there is no "undo" step. The optimistic value is a temporary overlay, and when the Action ends React renders the real value, which your code changed only if the save worked.

    Three rules from react.dev: the setter "must be called inside an Action" (a form action is one), or the optimistic state "will briefly render"; the reducer must be pure; and a plain setOptimistic(5) writes 5 even if the base value changed meanwhile, so use a reducer for relative changes. Note the startTransition calls after the await: react.dev documents as a known limitation that state updates after an await need their own startTransition.

    Actions are queued

    useActionState queues Actions. From react.dev: "React queues and executes multiple calls to dispatchAction sequentially. Each call to reducerAction receives the result of the previous call." That keeps a counter or cart quantity correct: the second Action starts from the first one's answer.

    Figure 3. Two dispatches, one request at a time

    sequenceDiagram accTitle: Queued Actions in useActionState accDescr: The user clicks Add 1 and then Add 10. Only the first request is sent. When it is answered the second request starts with the first result as its previous state. If the first action throws, the second never runs. participant U as User participant H as useActionState participant S as Server U->>H: dispatch(+1) H->>S: request 1 (previous 0, add 1) U->>H: dispatch(+10) Note over H: queued, nothing sent yet S-->>H: 1 H->>S: request 2 (previous 1, add 10) S-->>H: 11 Note over H: state is 11

    How to read this: the queue protects correctness and costs latency. Three quick taps take three round trips in a row.

    The lab checks both halves: the second request goes out only after the first is answered, with previous: 1, and when the first action throws the second is never sent. Plain Actions in a Transition have no such guarantee: "Actions within a Transition do not guarantee execution order". The documented queue is the calls to one hook's dispatchAction; the docs promise nothing across hooks.

    When the action runs on the server

    A Server Function moves the body to the server. You mark an async function with 'use server' (at the top of the function or of a file) and the client gets a reference instead of the code. react.dev: "When calling a Server Function on the client, it will make a network request to the server that includes a serialized copy of any arguments passed."

    On terms, react.dev: "If a Server Function is passed to an action prop or called from inside an action then it is a Server Action, but not all Server Functions are Server Actions." 'use server' does not mark Server Components; there is no directive for those.

    Figure 4. Where each piece runs

    A form in a Client Component calls a Server Function; the response can carry both a value and fresh UIForm to React Action (FormData); React Action to Server Function (POST, serialised args); Server Function to Data store (write); Server Function to React Action (value (and UI, Next.js))BROWSERSERVER (FRAMEWORK REQUIRED)Form: action prop, useActionStateFormaction prop,useActionStateReact Action: Transition, pending stateReact ActionTransition,pending stateServer Function: 'use server'ServerFunction'use server'Data store: the real mutationData storethe real mutationFormDataPOST, serialised argswritevalue (and UI, Next.js)

    How to read this: the form and the pending state stay in the browser. Everything inside the right zone is reachable from outside, because the request is an ordinary POST. This figure describes the model from the React and Next.js documentation; none of it was run for this lesson.

    What the documentation says about that boundary:

    • Arguments are untrusted. react.dev: "Arguments to Server Functions are fully client-controlled." Validate them and "make sure to validate that the logged-in user is allowed to perform that action". Next.js adds that rendering a form only on an authenticated page is "not a security boundary".
    • Arguments must be serialisable. Plain objects, arrays, primitives, Date and FormData cross the boundary; class instances, React elements and ordinary functions do not.
    • They are for mutations, not data fetching, and Next.js dispatches them one at a time per client, so Promise.all over them does not run in parallel.
    • Only POST invokes them in Next.js, with a CSRF check comparing the request's Origin to the Host, and a default request body limit of 1 MB that can be configured.
    • After the mutation, one response can carry both the return value and a re-rendered page, when the action calls revalidatePath, updateTag, refresh, changes cookies or redirects. revalidateTag with a stale-while-revalidate profile is the exception: it does not include a re-render in the response. A Server Action that calls none of these returns only its value.

    A Server Function passed to <form action> adds one property: "Users can submit that form before JavaScript loads or when JavaScript is disabled". Showing a returned error before hydration also needs a Client Component form and useActionState; the optional permalink argument names the URL the browser goes to for a pre-hydration submit. None of that was tested here: jsdom has no server and no hydration.

    The other side of this topic is reading data again after a mutation, which is a caching question; see rendering strategies.

    Choosing a tool

    Figure 5. Which mechanism for which job

    flowchart TD accTitle: Choosing how to run a mutation from the UI accDescr: A decision tree. A form submit with a pending and error need uses a form action. Several ordered updates use useActionState. A button that is not a form uses startTransition with an async function. Running on the server uses a Server Function and needs a framework. Immediate feedback adds useOptimistic. Q1{"Is it a form submit?"} -->|"yes"| F["form action={fn}"] Q1 -->|"no, a button or handler"| T["startTransition(async ...)"] F --> Q2{"Need the last result
    or ordered updates?"} T --> Q2 Q2 -->|"yes"| U["useActionState"] Q2 -->|"no"| Q3{"Is the body server code?"} U --> Q3 Q3 -->|"yes"| S["Server Function
    (framework needed)"] Q3 -->|"no"| Q4{"Must the screen change
    before the reply?"} S --> Q4 Q4 -->|"yes"| O["add useOptimistic"] Q4 -->|"no"| D["done"]

    How to read this: the choices stack. A realistic form often combines a form action, useActionState and a Server Function, and adds useOptimistic only where waiting would feel broken.

    Approach Pending state Errors Ordering Works before hydration Costs
    onSubmit plus useState flags You write it You write it You write it No Most code, most inconsistency
    <form action={clientFn}> useFormStatus in a child Boundary (throw), or render a returned value One Action per submit No Fields reset on success
    useActionState(fn, initial) isPending and useFormStatus Returned value, or Boundary (throw cancels the queue) Queued, in order Only with a Server Function Pending taps wait in a line
    Server Function as the action Same hooks Same, plus network errors Next.js dispatches one at a time per client Yes, per react.dev Needs a framework; public endpoint; serialisation limits
    useOptimistic on top Pending row you design Reverts when the Action ends Per Action n/a Must show failure clearly, or the user sees a value vanish

    Build it: watch each state in a held-open form

    Goal. Look at every state a form Action can be in (pending, returned error, thrown error, optimistic, queued) by holding the "server" request open and asserting what React shows.

    Setup. The lab is examples/frontend/labs/react-actions-forms, with React 19.3.1, Testing Library and jsdom (a JavaScript model of the DOM, not a browser). A fake server whose answers each test decides means no timers or network.

    cd examples/frontend/labs/react-actions-forms
    pnpm test          # 11 tests
    pnpm typecheck

    Steps.

    1. In src/controlledApi.ts, see that a request stays pending until the test settles it.
    2. In test/actions.test.tsx, group A submits the sign-up form, asserts "Saving..." and a disabled button while the request is open, then resolves it and asserts "Welcome".
    3. Group B: the optimistic row is pending while the request is open, and after success there is one row, not two.
    4. Group C: two clicks send one request, and the second carries the first reply as its previous value.

    The run for this lesson (React 19.3.1, 2026-11-08) printed these test names and passed 11 of 11:

    A. a form whose action is a function (useActionState + useFormStatus)
      returns an expected error as state: no request is sent, the message is rendered
      is pending while the request is open, then shows the returned state
      a returned (not thrown) error is a successful Action: the uncontrolled input is reset
      useFormStatus in the component that renders the form never reports pending
      a thrown error (rejected request) is shown by the nearest error boundary
      a plain function action (no useActionState) also reports a thrown error to the boundary
    B. useOptimistic inside a form Action
      shows a pending row at once, and on success replaces it with the saved row (no duplicate)
      on failure the optimistic row disappears by itself and the error is shown
      the server's version wins: if the server returns a different title, that is what remains
    C. Actions dispatched through useActionState are queued
      the second dispatch waits for the first and receives its result as the previous state
      when an action throws, the queued action never runs

    What this lab does not show. No Server Function ran: there is no serialisation, framework, hydration or JavaScript-off submit, and jsdom measures no time. Passing tests show what React 19.3.1 did under jsdom in this run, not what a browser or framework does.

    Break it. In TaskApp, comment out addOptimistic(title);. The three group B tests fail, because the pending row never appears. Restore the line.

    Stretch. Make QueuedCounter catch the error and return the previous count, so the second action runs after a failure, and decide what the user should see.

    In practice: a back-in-stock form and quantity stepper

    NOTE

    This is a realistic scenario, not a case history. It is a composite built from common production patterns. It describes no specific company or product, and every number in it is an illustrative assumption.

    The situation

    A shop's product page has a "Notify me" email box and a quantity stepper for the cart. The page uses a React framework with Server Components (Next.js here; its documentation is the source for the server behaviour below). Both forms use onSubmit, four useState flags and a hand-written fetch. QA finds that double-tapping "Notify me" sends two requests, that quick taps on the stepper show the wrong number, and that errors are sometimes swallowed. Three engineers own the page.

    Starting architecture

    Each form owns its pending, error and ordering flags and a hand-written fetch to an API route. Nothing orders the requests, and nothing guarantees that a flag is cleared on every path.

    Options considered

    Option What it fixes What it costs Risk
    Client form actions with useActionState Pending, errors and ordering are standard React 19 on the page Queued taps feel slow without feedback
    Server Function as the action Progressive enhancement, no hand-written route Framework dependency, public endpoint, auth in every function Stale clients after a deploy

    The decision

    Field Decision record
    Context Two forms hand-roll pending, error and ordering state. Taps overlap and errors are lost.
    Decision Use form actions with useActionState. Back the "Notify me" form with a Server Function that validates the email and checks the session. Use useOptimistic for the stepper only, with a visible error row on revert.
    Consequences, good One pattern for pending, errors and ordering. The "Notify me" form can work before hydration.
    Consequences, bad Another public endpoint to defend. Stepper taps are served in sequence.
    Revisit when Stepper latency is a complaint after rollout, or the framework changes how Server Functions are dispatched.

    Rollout and verification

    1. Write tests first like the lab: hold the request open and assert the pending, error and optimistic states.
    2. Send a request by hand, outside the form, with a wrong session and a malformed body. The Server Function must refuse both.
    3. Ship to a small share of traffic and compare submit errors and double submissions with the old flow.

    The 2 a.m. failure

    A new version is deployed in the evening. Shoppers who left the product page open since the afternoon tap "Notify me" and get an error. Support sees "Failed to find Server Action".

    The cause is documented by Next.js: each Server Action is identified by an action ID that is part of the build, new deployments typically generate new IDs, and "a client still running the previous build may invoke an action ID that no longer exists". The old page was not broken; it was old.

    First three checks: (1) match the error text in the logs against the time of the deploy; (2) check whether the affected sessions started before it; (3) confirm that a hard refresh fixes it. Mitigation: ask users to refresh, and prefer rolling deployments when many users are mid-mutation. Follow-up: the Next.js guide suggests surfacing this as a retry path rather than a hard failure and keeping NEXT_SERVER_ACTIONS_ENCRYPTION_KEY stable across instances.

    Architect's notes

    • Return what a user can fix; throw what only a developer can fix.
    • Design the failure state of every optimistic update. A row that silently disappears is a bug report waiting to happen.
    • Treat every Server Function as a public endpoint. Authenticate, authorise and validate inside it, and return only what the UI renders. See system design: security.
    • Count the cost of the queue. Ordered Actions are correct and slower. If users can tap faster than the server answers, show feedback.
    • Make retries safe. A double submit should not create two orders; see idempotent operations.

    Common mistakes

    • Reading the form data from the wrong argument. With useActionState, FormData is the second argument.
    • Calling useFormStatus in the form's own component. It reads the parent form, so it never reports pending there.
    • Throwing for expected failures. A validation error should be returned and rendered.
    • Forgetting the second startTransition after an await. That update is not part of the Transition.
    • Calling the optimistic setter outside an Action. React warns and the value flashes.
    • Assuming 'use server' protects the function. It makes it callable from the client; checking the caller is your job.
    • Expecting a returned error to keep the typed values. The form resets uncontrolled fields after any Action that does not throw (the lab's third test shows it). Return the values and use defaultValue.

    Key takeaways

    • An Action is a function run in a Transition; a <form action> function receives FormData, is pending through useFormStatus, resets uncontrolled fields on success and sends thrown errors to an Error Boundary.
    • useActionState(fn, initial) returns [state, formAction, isPending]; fn gets the previous state first and the FormData second, and its Actions run in order.
    • useOptimistic shows a temporary value only while an Action runs; success and failure both end by rendering the real value.
    • A Server Function ('use server') is called over the network with serialised arguments and is a Server Action when used as an action. Treat it as a public endpoint.
    • After a mutation, Next.js can return the value and a re-rendered page in one response; stale clients after a deploy can call action IDs that no longer exist.

    Practice exercises

    Exercise 1 - Starter (30 min): In the lab, add a name field to SignupForm, return it in the state, keep it with defaultValue, and write a test that shows it survives a returned error.

    Exercise 2 - Intermediate (2 hours): Add a "remove" button to TaskApp with an optimistic removal. Test success, failure, and two removals in a row, and note what the user sees if the second fails.

    Exercise 3 - Advanced (half day): In a scratch Next.js project of your own, move signupAction to a 'use server' file and send a request by hand with no session and a malformed body. Record what your validation does. (No framework was run for this lesson, so the result is yours to report.)

    In this course:

    Lessons on React Server Components and on optimistic updates and rollback are planned and not yet published.

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments