Skip to content

Rewrite useActionState#8284

Open
rickhanlonii wants to merge 10 commits intoreactjs:mainfrom
rickhanlonii:rh/uAS-revamp
Open

Rewrite useActionState#8284
rickhanlonii wants to merge 10 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

- **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
Contributor

Choose a reason for hiding this comment

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

It would be really nice to have an example we can point to here. Maybe we could add one to the useTransition docs (and link it from here), or include a small snippet right here that shows what the “run these in parallel with useState + useTransition” pattern looks like

@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!

---
## Troubleshooting {/*troubleshooting*/}

Choose a reason for hiding this comment

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

Not sure if here is a good spot, but I think people tend to lean towards useEffect when using the state return value of useActionState. For example, this long thread on showing a toast. In the end, couldn't come up with a solution that worked with progressive enhancement without it. I think the crux of the issue might be trying to mix uncontrolled apis with it.

Maybe something more for the Learn section, not sure.

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't really understand what the issue there is - you can't show a toast if there's no JS. So the flow is: permalink before JS (no toast), then "upgrade" with JS (the useActionState in the tweet). Could you post a question in the Async React working group explaining the use case?

- **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

@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 rickhanlonii changed the title [wip] Rewrite useActionState Rewrite useActionState Feb 6, 2026
@gaearon
Copy link
Member

gaearon commented Feb 6, 2026

One thing that's maybe worth calling out after the first demo is that we'll add optimistic handling in another demo (linked). It feels very broken that there's no immediate feedback.

* `reducerAction` can be sync or async. It can perform sync actions like showing a notification, or async actions like posting updates to a server.
* `reducerAction` is not invoked twice in `<StrictMode>` since `reducerAction` is designed to allow side effects.
* The return type of `reducerAction` must match the type of `initialState`. If TypeScript infers a mismatch, you may need to explicitly annotate your state type.
* If you set state after `await` in the `reducerAction` you currently need to wrap the state update in an additional `startTransition`. See the [startTransition](/reference/react/useTransition#react-doesnt-treat-my-state-update-after-await-as-a-transition) docs for more info.
Copy link

@MaxwellCohen MaxwellCohen Feb 6, 2026

Choose a reason for hiding this comment

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

[Suggestion] Add something about when reducerAction returns a promise to this caveat, because reducerAction can return a promise or a value that does not have to be an async function. Since reducerAction can return both promises and the value directly is an amazing feature of this hook.

If you set state after `await` in the `reducerAction` or `reducerAction` returns a promise You need to wrap the state update in an additional `startTransition`. See the [startTransition](/reference/react/useTransition#react-doesnt-treat-my-state-update-after-await-as-a-transition) docs for more info.

I can see not adding the last line and going with the stance that reducerAction must be called in a transition, as it could cause confusion vs developers saving a little bit of code

edit: While listening to Ryan Carniato's stream Sync to Async (https://www.youtube.com/watch?v=drLX0yTKP04), I think that having the strict useTransition message is better for overall useablity so dont't have a line like ' If reducerAction is sync (returns a value directly), startTransition is not needed. "

@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."

### Using with Action props {/*using-with-action-props*/}
When you pass the `dispatchAction` function to a component that exposes an [Action prop](/reference/react/useTransition#exposing-action-props-from-components), you don't need to call `startTransition` or `useOptmisitc` yourself.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
When you pass the `dispatchAction` function to a component that exposes an [Action prop](/reference/react/useTransition#exposing-action-props-from-components), you don't need to call `startTransition` or `useOptmisitc` yourself.
When you pass the `dispatchAction` function to a component that exposes an [Action prop](/reference/react/useTransition#exposing-action-props-from-components), you don't need to call `startTransition` or `useOptimistic` yourself.

Comment on lines +68 to +70
`dispatchAction` must be called within a Transition.
You can wrap it in [`startTransition`](/reference/react/startTransition), or pass it to an [Action prop](/reference/react/useTransition#exposing-action-props-from-components).
Copy link
Contributor

Choose a reason for hiding this comment

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

Above, in the Returns section it was mentioned that:

A dispatchAction function that you call inside Actions

I found a bit confusing that in this Note it is mentioned that a "dispatchAction must be called within a Transition."

I suggest something like:

Suggested change
`dispatchAction` must be called within a Transition.
You can wrap it in [`startTransition`](/reference/react/startTransition), or pass it to an [Action prop](/reference/react/useTransition#exposing-action-props-from-components).
`dispatchAction` must be called from an Action (i.e. inside startTransition), or passed to an Action prop.
Calls outside that scope won’t be treated as part of the Transition and log an error on development mode.

<Intro>

`useActionState` is a Hook that allows you to update state based on the result of a form action.
`useActionState` is a React Hook that lets you update state with side effects using [Actions](/reference/react/useTransition#functions-called-in-starttransition-are-called-actions).

Choose a reason for hiding this comment

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

nit: The "with side effects" threw me a bit until I realized (I think) that it's referring to the actionReducer being able to contain side effects?

Not sure of what's the best wording here, but yea, just wanted to leave my thoughts.

#### Caveats {/*caveats*/}

* `useActionState` is a Hook, so you can only call it **at the top level of your component** or your own Hooks. You can't call it inside loops or conditions. If you need that, extract a new component and move the state into it.
* React queues and executes multiple calls to `dispatchAction` sequentially, allowing each `reducerAction` to use the result of the previous Action.

Choose a reason for hiding this comment

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

nit: "allowing each call to reducerAction to use..." or "allowing each reducerAction invocation to use..."?

"each reducerAction to use" seems like it's implying there are multiple reducerActions.

}
```

Each time you call `dispatchAction`, React calls the `reducerAction` with the `actionPayload`. The reducer will perform side effects such as posting data, and return the new state. If `dispatchAction` is called multiple times, React queues and executes them in order so the result of the previous call is available for the current call.

Choose a reason for hiding this comment

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

nit: "available to the current call" or "available for the current call to use".

It just read a bit off.


#### Parameters {/*reduceraction-parameters*/}

* `previousState`: The current state. Initially this is equal to the `initialState`. After the first call to `dispatchAction`, it's equal to the last state returned.

Choose a reason for hiding this comment

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

It did make me pause to see it called previousState and then immediately read "The current state". I get it, but did I have to think. In the new useOptimistic docs it says currentState for the optional reducer, but I know "previous" also tends to be used. Just kinda confusing to read it like this.


* `previousState`: The current state. Initially this is equal to the `initialState`. After the first call to `dispatchAction`, it's equal to the last state returned.

* **optional** `actionPayload`: The argument passed to `dispatchAction`. It can be a value of any type. Similar to `useReducer` conventions, it is usually an object with a `type` property identifying it and, optionally, other properties with additional information.

Choose a reason for hiding this comment

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

You mention initialState needs to be serializable when using Server Functions, but the same also holds for actionPayload right? Should that be mentioned in the caveats section below?


function handleDecrease() {
startTransition(async () => {
setOptimisticValue(c => c + 1);

Choose a reason for hiding this comment

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

Small bug.

Suggested change
setOptimisticValue(c => c + 1);
setOptimisticValue(c => c - 1);

Also, I guess https://github.com/reactjs/react.dev/pull/8284/changes#r2776729030 is relevant here as well.

When you use `useActionState`, the `reducerAction` receives an extra argument as its first argument: the previous or initial state. The submitted form data is therefore its second argument instead of its first.
```js

Choose a reason for hiding this comment

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

Small thing, not sure.

Suggested change
```js
```js {2,7}


function handleRemove() {
startTransition(() => {
setOptimisticCount(c => c - 1);

Choose a reason for hiding this comment

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

nit: Maybe not worth the additional complexity, but this allows negative tickets whereas removeFromCart doesn't and will revert to 0.

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