skip to content

Form and Action Hooks

You will learn the React 19 hooks built around actions: useActionState for a pending flag plus returned state, useFormStatus for a child reading its enclosing form's submission, and useOptimistic for showing a result before the server confirms it. Interviewers ask these to check whether you have kept up with the form story that replaces hand-rolled isSubmitting state.

part ofReactoverview, primer and where to startread it →
on this pageshow

explore

questions

5

In React 19, what does the useActionState(action, initialState) hook return, and what arguments does React pass to the action function when the returned action runs as a form's submit handler?

level: middleimportance: must knowfreq 60%

answer

  1. three values, not two
  2. state is whatever the action returned
  3. the flag comes from React, not your finally
  4. two parameters reach the action
  5. previous state first, FormData second

basics

~20 s

useActionState returns a three-element array: [state, formAction, isPending] — the action's most recent return value, an action to hand to a form's action prop, and a boolean that is true while it runs. React calls the action with (previousState, formData).

solid answer

~40 s

`useActionState` takes the function you want to run and an initial state, and returns `[state, formAction, isPending]`. `state` starts as `initialState` and afterwards is whatever the action returned last. `formAction` is a wrapped action you pass to `<form action={formAction}>` or to a button's `formAction` prop. `isPending` is `true` from the moment the action starts until it settles, so you never write your own `isSubmitting` boolean. The key signature detail is that React calls your action with **two** arguments — the previous state first, then the `FormData`: `async function submit(prevState, formData)`. That ordering lets you accumulate across submissions, for example counting attempts or merging validation errors. It is imported from `react` in React 19; the older canary version was `useFormState` from `react-dom`, and an optional third argument to `useActionState` takes a URL used before hydration.

code

jsx · 25 lines
jsx
import { useActionState } from 'react';

async function submit(previousState, formData) {
  const email = formData.get('email');
  if (!email || !email.includes('@')) {
    return { error: 'Enter a valid email', attempts: previousState.attempts + 1 };
  }
  try {
    await fetch('/api/subscribe', { method: 'POST', body: formData });
    return { error: null, attempts: previousState.attempts + 1 };
  } catch {
    return { error: 'Network error, try again', attempts: previousState.attempts + 1 };
  }
}

export function Subscribe() {
  const [state, formAction, isPending] = useActionState(submit, { error: null, attempts: 0 });
  return (
    <form action={formAction}>
      <input name="email" type="email" />
      <button type="submit" disabled={isPending}>Subscribe</button>
      {state.error && <p role="alert">{state.error}</p>}
    </form>
  );
}

go deeper

for a junior

Memorise the destructuring: const [state, formAction, isPending] = useActionState(fn, initial), and that formAction — not your original function — is what goes on the form's action prop.

for a middle

Explain that the action is called as (previousState, formData) and that its return value becomes the next state, so validation errors are returned rather than thrown. Mention that isPending replaces a hand-rolled isSubmitting boolean.

for a senior

Show that you treat it as an async reducer: design the returned state shape so success clears prior errors, and know that an uncaught throw inside the action escapes to the error boundary instead of becoming state.

for a principal

Be ready to set a house rule: when the returned state is the single source of submission truth versus when a data-layer cache should own the result, and what you accept as the cost of coupling the result shape to one form component.

## The shape ```jsx const [state, formAction, isPending] = useActionState(action, initialState); ``` Three values come back, in this order: 1. **`state`** — on the first render it is exactly the `initialState` you passed. After the action has run at least once, it is whatever that action **returned**. If your action returns nothing, `state` becomes `undefined`, which is a real and easily-missed footgun. 2. **`formAction`** — a function React created by wrapping yours. You do not call it directly in the common case; you hand it to a `<form action={formAction}>` or to a `<button formAction={formAction}>`. 3. **`isPending`** — `true` while the action is in flight, `false` otherwise. React derives it from the action's own lifetime, so there is no `finally` block you can forget. There is an optional third parameter, `useActionState(action, initialState, permalink)`, taking a URL that a form can fall back to before the page has hydrated. ## What React passes into your action This is the part interviewers actually probe, because it differs from a bare `<form action={fn}>`: ```jsx async function submit(previousState, formData) { const email = formData.get('email'); if (!email.includes('@')) { return { ...previousState, error: 'Enter a valid email' }; } await api.subscribe(email); return { error: null, count: (previousState.count ?? 0) + 1 }; } ``` The **previous state comes first** and the `FormData` second. A plain form action receives only the `FormData`; the extra leading parameter is what makes this hook a *state* hook rather than just a submit handler. Because the action's return value becomes the next `state`, one action both performs the work and reports the result, which is why you return validation errors instead of throwing them. A useful mental model: it is `useReducer` shaped for asynchronous submissions. The action plays the reducer's role — `(previousState, input) => nextState` — except the input is the submitted `FormData`, it may be async, and React tracks how long it takes. ## Wiring it up ```jsx function Subscribe() { const [state, formAction, isPending] = useActionState(submit, { error: null }); return ( <form action={formAction}> <input name="email" type="email" /> <button type="submit" disabled={isPending}> {isPending ? 'Subscribing…' : 'Subscribe'} </button> {state.error && <p role="alert">{state.error}</p>} </form> ); } ``` Note that the form's `action` prop gets `formAction`, **not** the original `submit`. Passing the raw function still works as a form action, but then React calls it with only the `FormData`, no state is threaded back, and `isPending` never flips — one of the most common ways this hook silently does nothing. ## Why the state channel matters Without the hook you would keep a `useState` for the result, another for the error, and a third for the pending flag, then set them by hand in a `try`/`catch`/`finally`. Three pieces of state that must be kept consistent become one value that only changes where it can change: at the end of the action. It also removes a class of bug where a slow submission's `finally` clears a flag that a newer submission just set. ## Details that come up - **The action can be sync or async.** React awaits the result either way. - **Errors propagate.** If the action throws, the error reaches the nearest error boundary rather than becoming state. For expected failures, return an error value. - **The returned action's identity is stable enough to pass down** as a prop to a child that renders the form. - **`state` is not reset for you.** Nothing clears an error automatically on the next submission — your action decides, which is why returning a fresh object such as `{ error: null }` on success matters. - **Naming history.** In React 18 canaries the hook was `useFormState`, imported from `react-dom`, and it returned only two values. React 19 renamed it to `useActionState`, moved it into `react`, and added the `isPending` element. Interviewers use this to check whether you have actually written React 19 or are reciting a blog post. ## Pending flag ownership `isPending` is readable wherever the hook is called — the component holding the hook. It is not context, so a deeply nested button cannot pick it up on its own; you either pass it down or let that child read the enclosing form's submission status itself.

  • A team passes the original action to <form action={submit}> instead of the formAction the hook returned. What breaks?
    Everything the hook provides. React then calls the raw function with only the `FormData`, so the previous-state parameter is missing, the return value is discarded rather than becoming `state`, and `isPending` never turns true because React is not tracking that action through the hook. The form still submits, which is why the mistake survives review.
  • Why does the previous state come first in the action's parameter list rather than last?
    It mirrors a reducer's `(state, action)` shape and keeps the platform-supplied argument last. React always appends the `FormData` it built, so any leading parameters are ones the framework threads through — which is also how a partially applied action (bound with extra arguments up front) still lines up correctly.
  • How do you surface a validation failure from the action without hitting an error boundary?
    Return it as state instead of throwing. Catch expected failures inside the action and return something like `{ error: 'Email already registered' }`; React stores it as the hook's `state` and re-renders. Throwing is reserved for genuinely exceptional failures, because an uncaught throw inside the action propagates to the nearest error boundary and unmounts the form.

saying these in an interview costs you the question

  • Says it returns two values, forgetting isPending
  • Thinks the action receives only the FormData
  • Passes the original function to the form instead of the returned action
  • Claims it is imported from react-dom in React 19
  • Expects the error state to clear itself on the next submit

context

open as a page

A React 19 component renders <form action={submit}> and calls useFormStatus() in that same component to disable its button, but pending is always false. Why does that happen, and where must useFormStatus() be called instead?

level: middleimportance: must knowfreq 52%

basics

~20 s

useFormStatus reports on the nearest ancestor form, so a component that renders the form is not inside it and always sees pending false. Call the hook in a child component rendered within the form. It is exported by react-dom.

open as a page

In React 19, when you pass a function to a form element's action prop (<form action={saveNote}>), what single argument does React call that function with, and what does React do to the form's uncontrolled inputs after the function finishes?

level: juniorimportance: should knowfreq 45%

basics

~20 s

React calls the function with one argument, a FormData object built from the form's named fields. There is no submit event, so nothing to preventDefault. After the function finishes, React automatically resets the form's uncontrolled inputs.

open as a page

In React 19, what are the two arguments to useOptimistic, what does it return, and at what moment does the optimistic value stop being rendered?

level: middleimportance: should knowfreq 42%

basics

~10 s

useOptimistic(state, updateFn) returns [optimisticState, addOptimistic]. Calling addOptimistic(value) inside an action immediately renders updateFn(currentState, value); when that action settles React drops the optimistic layer and renders the real state argument again.

open as a page

A React 19 form gets an isPending flag from useActionState and its submit button reads pending from useFormStatus. What does each of those flags actually track, and which one belongs in a shared SubmitButton component used across many forms?

level: seniorimportance: should knowfreq 38%

basics

~20 s

useActionState's isPending tracks that one specific action and is readable only where the hook is called. useFormStatus().pending tracks the enclosing form's submission and is readable from any descendant, so a shared SubmitButton reads that one and needs no props.

open as a page