Skip to content

Rewrite useActionState#8284

Merged
aurorascharff merged 12 commits intoreactjs:mainfrom
rickhanlonii:rh/uAS-revamp
Feb 15, 2026
Merged

Rewrite useActionState#8284
aurorascharff merged 12 commits intoreactjs:mainfrom
rickhanlonii:rh/uAS-revamp

Conversation

@rickhanlonii
Copy link
Member

@rickhanlonii rickhanlonii commented Feb 3, 2026

Preview

  • First commit: claude
  • Second commit: my edits

I need to do some more passes, but it's ready to review.

cc @samselikoff @gaearon @stephan-noel @aurorascharff @brenelz @MaxwellCohen @hernan-yadiel

Goals

  • Client action first (with a mention of form actions / server functions)
  • explain queuing actions (aka, so you can reduce them
  • explain how to "fix" queing (aka optimistic state, or cancelling)
  • sandbox based usage examples

the usage examples build up from:

  • 1 action
  • 2 actions
  • 2 actions with pending states (via action props)
  • 2 actions with pending and optimistic states
  • 2 actions with a

Terms

Edit: I went with dispatchAction.

I struggled with what to call the returned function and the reducer in the signature

const [_, dispatchAction, _] = useActionState(reducerAction, _);

I landed on dispatchAction because:

  • it should have dispatch because it dispatches like useReducer
  • it should use the "action" name, since it's called in a transition, so not just dispatch
  • it's wordy, but shortening would make it unclear

I landed on reducerAction because:

  • it has a reducer signature with the first arg
  • it's an "action" so the returned state is updated in a transition
  • it is a reducer inside an action, so it can do side effects

One wierd naming thing is this:

dispatchAction({type: 'Add'})

What do you call the argument passed to the action? useReducer calls it an "action", so that would mean it's

call action with the action as the only argument.

So I called it actionPayload. It's the payload passed to the action.

TODO

  • Usage example for how it can be used for error ui.

Followups

  • Example of using useState and useTransition directly in useTransition docs.
  • Full permalink example

@github-actions
Copy link

github-actions bot commented Feb 3, 2026

Size changes

Details

📦 Next.js Bundle Analysis for react-dev

This analysis was generated by the Next.js Bundle Analysis action. 🤖

This PR introduced no changes to the JavaScript bundle! 🙌

Copy link

@MaxwellCohen MaxwellCohen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much better than the current state at showing the value of UseActionState outside forms. UseActionState seems to be an async/actions version of useReducer, so adding more parallel language with the useReducer docs to show the value of useActionState.

Thank you for cleaning up these pages

@samselikoff
Copy link
Contributor

I struggled with what to call the returned function and the reducer in the signature

const [_, action, _] = useActionState(reducerAction);

Kinda feels like we need a new term. I really like the existing docs on reducers and would eventually love to see a similar build-up of the logic for action reducers. Show how you can do it on your own, then show how useActionState basically provides sugar over that.

What about asyncReducer?

const [state, asyncDispatch, isPending] = useActionState(asyncReducer, initialState, permalink?);

and

async function yourAsyncReducer(state, action) {
  // await any async functions, then return next state for React to set
}

If we eventually have a learn page around this stuff, we can explain the differences between "async reducers" and "reducers". asyncDispatch feels like a strong enough convention to hint that it must be called within a transition. And asyncReducer hints that "this is no ordinary reducer". This reducer can have side effects.

@samselikoff
Copy link
Contributor

In retrospect, and in light of your recent "Async React" branding (which I think is fantastic), maybe the hook could have been called useAsyncReducer ^_^

@samselikoff
Copy link
Contributor

I wonder if "preserving a form's inputs after a failed submission" warrants its own section in Usage. It's such a common one and not entirely obvious how to use defaultValue to pull it off (given React 19 resets forms):

import { login } from "./actions";

function Form() {
  const [state, asyncDispatch, isPending] = useActionState(
    async (prev, formData) => {
      const { name, password } = Object.fromEntries(formData);

      try {
        await login(name, password);
        return { status: "success" };
      } catch (error) {
        return { status: "error", error: error.toString(), formData };
      }
    },
    { status: "init" },
  );

  return (
    <form action={asyncDispatch}>
      <input
        type="email"
        name="email"
        defaultValue={state.formData?.get("email") ?? ""}
      />
      {state.status === "error" && <p>{state.error}</p>}
      <input
        type="password"
        name="password"
        defaultValue={state.formData?.get("password") ?? ""}
      />
    </form>
  );
}

@samselikoff
Copy link
Contributor

Another one that's not obvious is that you can mix async and sync code branches in the asyncReducer. Nice for resetting state or anything else that doesn't involve a side effect.

async function updateCart(state, formData) {
  const type = formData.get("type");
  switch (type) {
    case "ADD": {
      return await addToCart(state.prevCount);
    }
    case "REMOVE": {
      return await removeFromCart(state.prevCount);
    }
    case "RESET": {
      return state.initialCount; // no async calls
    }
    default: {
      throw Error("Unknown action: " + type);
    }
  }
}

I've seen tons of folks in comments feeling like they're stuck with whatever state was returned from the previous server function, when they can just add a branch to reset a form all in the client.

@rickhanlonii
Copy link
Member Author

@samselikoff re: naming - the actions don't need to be async or could be a mix of sync and async. For example, you could have useActionState where the "action" side effect is to call showNotification (a synchronous API)

@samselikoff
Copy link
Contributor

@samselikoff re: naming - the actions don't need to be async or could be a mix of sync and async. For example, you could have useActionState where the "action" side effect is to call showNotification (a synchronous API)

Yep I mentioned that above, but the fact that they can be async (and normal reducers cannot) seems to be a pretty crucial differentiator!


- **Use `useActionState`** to manage state of your Actions. The reducer can perform side effects.

You can think of `useActionState` as `useReducer` for side effects from user Actions. Since it computes the next Action to take based on the previous Action, it has to [order the calls sequentially](/reference/react/useActionState#how-useactionstate-queuing-works). If you want to perform Action in parallel, use `useState` and `useTransition` directly.
Copy link
Collaborator

@stephan-noel stephan-noel Feb 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I were to summarize the main use cases of useActionState as 1) queing updates to resolve out of order updates and 2) progressive enhancement for showing feedback and navigating (only for forms), would that be correct?

I feel like the why behind it is too buried here and a summary of it (with some more focus on the problem it solves) near the beginning would be helpful.

I'm also curious, why was this hook created? Like what was the main problem it was trying to solve and which features are just coincidental or add-ons? I know we started with useFormState and then tried to generalize, but now the overlapping use cases and motivation make it all kinda fuzzy to me especially as usage with forms isn't emphasized as much.

PS: reading facebook/react#28491 now to see if it clicks.

@stefanprobst
Copy link

I wonder if "preserving a form's inputs after a failed submission" warrants its own section in Usage.

also the "Save draft" example here: https://react.dev/reference/react-dom/components/form#handling-multiple-submission-types should be updated to not clear the input when saving the draft

@rickhanlonii
Copy link
Member Author

@samselikoff re: form action reset - I think we should add that to the form action docs here.

The form action docs need a revamp of their own

@rickhanlonii
Copy link
Member Author

@gaearon yeah good idea - but there is feedback right? the quantity and the total show a spinner (though really it should be on the button)

@gaearon
Copy link
Member

gaearon commented Feb 6, 2026

Ah yeah, I just meant it feels very strange for a number to not increase

@gaearon
Copy link
Member

gaearon commented Feb 6, 2026

Btw I'm also maybe team "async reducer"..

@gaearon
Copy link
Member

gaearon commented Feb 6, 2026

Or maybe "action reducer"

@rickhanlonii
Copy link
Member Author

Yeah I can see that, especially in the second example more than the first.

The naming

  • async reducer is maybe nice, but when you start explaining it there's no continuity of terms.

I also like how:

  • useReducer: dispatch -> reducer.
  • useActionState: dispatchAction -> reducerAction.
    • Since it's an Action, you can call useOptimistic in it, perform (async) side effects, etc.

In other words, since Action means "side effect that is maybe async", it is a async reducer but that's only a subset.

@rickhanlonii
Copy link
Member Author

rickhanlonii commented Feb 6, 2026

With the "async reducer" naming - what is the Action you're tracking the state of for useActionState? It's not the dispatch. The Action is the reducer, or there are multiple Actions in the reducer.

@rickhanlonii
Copy link
Member Author

Ok @gaearon I just pushed a change I'm pretty excited about, this feels clean af now.

I updated all the pending states, so the feedback is better. More importantly, I moved the useOptimistic example up, and added useOptimistic to the "Using with Action props" so that the design component adds the optimistc state.

Which lets me explain this:

Since <QuantityStepper> has built-in support for transitions, pending state, and optimistically updating the count, you just need to tell the Action what to change, and how to change it is handled for you.

So the first three examples kinda build it up, and just when you think it's getting complicated, the Action prop example simplifies it all.

@rickhanlonii
Copy link
Member Author

rickhanlonii commented Feb 6, 2026

The abortable section could be a re-usable hook btw:

function useAbortableActionState(reducerAction, initialState) {
  const abortRef = useRef(null);
  const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState);

  async function wrappedAction(payload) {
    if (abortRef.current) {
      abortRef.current.abort();
    }
    abortRef.current = new AbortController();
    await dispatchAction({ type: 'ADD', signal: abortRef.current.signal });
  }

  return [state, wrappedAction, isPending];
}

@MaxwellCohen
Copy link

We are missing a troubleshooting section for the following console errors (it might be implied, but not exact text).

  • "An async function with useActionState was called outside of a transition. This is likely not what you intended (for example, isPending will not update correctly). Either call the returned function inside startTransition, or pass it to an action or formAction prop."
  • "Cannot update form state while rendering."

@rickhanlonii
Copy link
Member Author

Ok I think this is good to go if someone want's to stamp it

@aurorascharff aurorascharff merged commit 55a317d into reactjs:main Feb 15, 2026
6 checks passed
@samselikoff
Copy link
Contributor

My first reaction on re-reading was that reducerAction should be called actionReducer... isn't the thing you're defining a reducer? Isn't the reducer the noun?

@rickhanlonii
Copy link
Member Author

Yeah I agree @samselikoff, but the convention is that Action is a suffix

@rickhanlonii rickhanlonii deleted the rh/uAS-revamp branch February 15, 2026 17:26
@samselikoff
Copy link
Contributor

But only on the public API side, right? Like if I define or expose an updateAction. Whereas this is an argument, not a function called by the user.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

9 participants