C.W.K.
Stream
Lesson 02 of 06 · published

useActionState — Pending, Error, Result for Free

~14 min · useactionstate, react-19, actions

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
useActionState wraps an Action with state management. The result of every submit becomes new state. Pending is tracked automatically. No more setIsLoading dances.

The signature

const [state, formAction, isPending] = useActionState(actionFn, initialState) where actionFn is (prevState, formData) => Promise<newState>. The hook returns:

  • state — the latest state (initial on first render, then whatever the last action returned).
  • formAction — the wrapped function you bind to <form action={...}>.
  • isPending — true while the action is in-flight.

The reducer-shaped action

Your actionFn looks like a useReducer reducer that also accepts FormData. The first argument is the previous state; the second is the FormData submitted. The return value (or resolved promise value) becomes the new state.

Why this shape? Because errors and success use the same return path. You return { error: '...', input: ... } on failure (preserving the user's input for re-display) and { data: ... } on success. The component decides what to render based on what's in state.

The 'previous state has the form values' pattern

When validation fails server-side, you want to re-display the form with the user's submitted values. Return them as part of the error state. The form's uncontrolled inputs will be re-populated by React (it uses the state's input fields as defaultValue values).

useActionState vs useState

useState gives you arbitrary state with arbitrary setters. useActionState gives you state that's tied to a single action's success/failure cycle. They compose: useState for client-only UI state (drafts, toggles), useActionState for submit-driven state.

The action IS the state machine. Each submit transitions state from 'before' to 'after.' By making your action return the full next state (success or error), you collapse the entire submission lifecycle into one function call. The hook handles the rest.

Code

Saving a title with useActionState·tsx
import { useActionState } from "react";

type State = {
  title: string;
  error: string | null;
};

const initialState: State = { title: "", error: null };

async function saveTitle(_prev: State, formData: FormData): Promise<State> {
  const title = (formData.get("title") as string).trim();
  if (!title) return { title, error: "Title cannot be empty" };
  try {
    await fetch("/api/conversations/current/title", {
      method: "PATCH",
      body: JSON.stringify({ title }),
    });
    return { title, error: null };
  } catch (e) {
    return { title, error: (e as Error).message };
  }
}

export function TitleEditor() {
  const [state, formAction, isPending] = useActionState(saveTitle, initialState);
  return (
    <form action={formAction} className="space-y-2">
      <input
        name="title"
        defaultValue={state.title}
        disabled={isPending}
        className="w-full px-3 py-2 border"
      />
      {state.error && (
        <p className="text-danger text-sm">{state.error}</p>
      )}
      <button
        type="submit"
        disabled={isPending}
        className="px-4 py-2 bg-brand text-bg rounded disabled:opacity-50"
      >
        {isPending ? "Saving…" : "Save"}
      </button>
    </form>
  );
}

External links

Exercise

Build a <SubscribeForm> with an email input. Use useActionState. The action validates the email (regex check), POSTs to a fake endpoint, and returns either { email, error: 'invalid' | 'conflict' | null } for failure or success states. The form re-renders the input with defaultValue={state.email} so the value persists across submits. Disable inputs while pending. Toggle a 'Subscribed!' message on success.
Hint
For the regex: /^[^@\s]+@[^@\s]+\.[^@\s]+$/. For conflict, hard-code that emails containing 'taken' produce a conflict to test the path.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.