From 3fa394e127f098fa35d3df7d44ddd528feb54a36 Mon Sep 17 00:00:00 2001 From: Rick Hanlon Date: Sat, 31 Jan 2026 14:01:19 -0500 Subject: [PATCH 1/2] Claude update for useActionState --- src/content/reference/react/useActionState.md | 965 +++++++++++++++--- 1 file changed, 819 insertions(+), 146 deletions(-) diff --git a/src/content/reference/react/useActionState.md b/src/content/reference/react/useActionState.md index f83f6bdc710..03cb82e6e4e 100644 --- a/src/content/reference/react/useActionState.md +++ b/src/content/reference/react/useActionState.md @@ -4,269 +4,942 @@ title: useActionState -`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 track the state of an [Action](/reference/react/useTransition#functions-called-in-starttransition-are-called-actions). ```js -const [state, formAction, isPending] = useActionState(fn, initialState, permalink?); +const [state, action, isPending] = useActionState(reducerAction, initialState, permalink?); ``` - - -In earlier React Canary versions, this API was part of React DOM and called `useFormState`. - - - - --- ## Reference {/*reference*/} -### `useActionState(action, initialState, permalink?)` {/*useactionstate*/} +### `useActionState(reducerAction, initialState, permalink?)` {/*useactionstate*/} -{/* TODO T164397693: link to actions documentation once it exists */} - -Call `useActionState` at the top level of your component to create component state that is updated [when a form action is invoked](/reference/react-dom/components/form). You pass `useActionState` an existing form action function as well as an initial state, and it returns a new action that you use in your form, along with the latest form state and whether the Action is still pending. The latest form state is also passed to the function that you provided. +Call `useActionState` at the top level of your component to create state for the result of an Action. ```js -import { useActionState } from "react"; +import { useActionState } from 'react'; -async function increment(previousState, formData) { - return previousState + 1; +function reducerAction(state, action) { + // ... } -function StatefulForm({}) { - const [state, formAction] = useActionState(increment, 0); - return ( -
- {state} - -
- ) +function MyComponent() { + const [state, action, isPending] = useActionState(reducerAction, {quantity: 1}); + // ... + } ``` -The form state is the value returned by the action when the form was last submitted. If the form has not yet been submitted, it is the initial state that you pass. - -If used with a Server Function, `useActionState` allows the server's response from submitting the form to be shown even before hydration has completed. - [See more examples below.](#usage) #### Parameters {/*parameters*/} -* `fn`: The function to be called when the form is submitted or button pressed. When the function is called, it will receive the previous state of the form (initially the `initialState` that you pass, subsequently its previous return value) as its initial argument, followed by the arguments that a form action normally receives. -* `initialState`: The value you want the state to be initially. It can be any serializable value. This argument is ignored after the action is first invoked. -* **optional** `permalink`: A string containing the unique page URL that this form modifies. For use on pages with dynamic content (eg: feeds) in conjunction with progressive enhancement: if `fn` is a [server function](/reference/rsc/server-functions) and the form is submitted before the JavaScript bundle loads, the browser will navigate to the specified permalink URL, rather than the current page's URL. Ensure that the same form component is rendered on the destination page (including the same action `fn` and `permalink`) so that React knows how to pass the state through. Once the form has been hydrated, this parameter has no effect. - -{/* TODO T164397693: link to serializable values docs once it exists */} +* `reducerAction`: The function to be called when the Action is triggered. When called, it receives the previous state (initially the `initialState` you provided, then its previous return value) as its first argument, followed by the arguments passed to the `action`. +* `initialState`: The value you want the state to be initially. It can be any serializable value (a value that can be converted to JSON). React ignores this argument after invoking the action for the first time. +* **optional** `permalink`: A string containing the unique page URL that this form modifies. For use on pages with [React Server Components](/reference/rsc/server-components) with progressive enhancement (allowing the form to work before JavaScript loads): if `reducerAction` is a [Server Function](/reference/rsc/server-functions) and the form is submitted before the JavaScript bundle loads, the browser will navigate to the specified permalink URL rather than the current page's URL. #### Returns {/*returns*/} -`useActionState` returns an array with the following values: +`useActionState` returns an array with exactly three values: -1. The current state. During the first render, it will match the `initialState` you have passed. After the action is invoked, it will match the value returned by the action. -2. A new action that you can pass as the `action` prop to your `form` component or `formAction` prop to any `button` component within the form. The action can also be called manually within [`startTransition`](/reference/react/startTransition). -3. The `isPending` flag that tells you whether there is a pending Transition. +1. The current state. During the first render, it will match the `initialState` you passed. After the action is invoked, it will match the value returned by the `reducerAction`. +2. An `action` function that you call inside [Actions](/reference/react/useTransition#functions-called-in-starttransition-are-called-actions). +3. The `isPending` flag that tells you whether there is a pending Action. #### Caveats {/*caveats*/} -* When used with a framework that supports React Server Components, `useActionState` lets you make forms interactive before JavaScript has executed on the client. When used without Server Components, it is equivalent to component local state. -* The function passed to `useActionState` receives an extra argument, the previous or initial state, as its first argument. This makes its signature different than if it were used directly as a form action without using `useActionState`. +* React queues and executes multiple calls to `action` sequentially so the `reducerAction` can be called with the previous result of the Action. This is by design: in order to trigger side effects based on the previous result, you have to wait for the previous `action` to finish to know its result. +* `useActionState` is a Hook, so it must be called **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. +* The `action` function has a stable identity, so you will often see it omitted from Effect dependencies, but including it will not cause the Effect to fire. If the linter lets you omit a dependency without errors, it is safe to do. [Learn more about removing Effect dependencies.](/learn/removing-effect-dependencies#move-dynamic-objects-and-functions-inside-your-effect) +* When using the `permalink` option, ensure the same form component is rendered on the destination page (including the same `reducerAction` and `permalink`) so React knows how to pass the state through. Once the page becomes interactive, this parameter has no effect. +* Calling `action` during render throws an error: "Cannot update form state while rendering." +* If `action` throws an error, React cancels all subsequent queued actions, and shows the nearest [error boundary](/reference/react/Component#catching-rendering-errors-with-an-error-boundary). To dispatch new actions after an error boundary catches an error, the component must remount (the action queue is cleared when the error boundary renders). +* If the component unmounts while `isPending` is true, any pending actions will complete but their state updates are silently ignored. React will not show an "update on unmounted component" warning. + + + +When calling the `action` function, you must wrap the call in [`startTransition`](/reference/react/startTransition). If you call `action` without `startTransition`, the `isPending` flag will not update correctly, and React will show a warning in development. + + + +### `reducerAction` function {/*reduceraction*/} + +The `reducerAction` function passed to `useActionState` receives the previous state and returns a new state, similar to the `reducer` passed to `useReducer`. However, the similarity ends there—`reducerAction` is designed for async operations with side effects, not pure synchronous state logic. + +Unlike the `reducer` in `useReducer`, the `reducerAction` can be async and perform side effects: + +```js +async function reducerAction(previousState, update) { + const newState = await post(update); + return newState; +} + +function MyCart({initialCart}) { + const [state, action, isPending] = useActionState(reducerAction, initialCart); + // ... +} +``` + +For each call to `action`, React calls the `reducerAction` to perform side effects and compute the result of that Action. If the `action` is called multiple times, React queues and executes them in order so the result of the previous call is available for current call. + +#### Parameters {/*reduceraction-parameters*/} + +* `previousState`: The current state of the Action. Initially this is equal to the `initialState`. After the first call to `action`, it's equal to the last state returned. + +* `update`: The argument passed to `action`. 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. + +#### Returns {/*reduceraction-returns*/} + +`reducerAction` returns the new state, and triggers a re-render with that state. + +#### Caveats {/*reduceraction-caveats*/} + +* Unlike `useReducer`, the `reducerAction` for `useActionState` is not invoked twice in StrictMode. This is because the `reducer` for `useReducer` must be pure (without side effects), but the `reducerAction` for `useActionState` is designed to allow side-effects. However, the component using `useActionState` may still double-render in StrictMode. Calling `action` in event handlers is fine (the handler only runs once), but be careful with calling `action` in Effects—the Effect may run twice in development. + + + +Despite the "reducer" in its name, `reducerAction` is not a pure reducer like those used with `useReducer` or Redux. It can be async and perform side effects. See ["Why is it called reducerAction?"](#why-reduceraction-name) below for more details. + + + + + +#### Why is it called reducerAction? {/*why-reduceraction-name*/} + +The name comes from its function signature: `(previousState, payload) => newState`. This matches the classic reducer pattern where the new state is computed from the previous state plus some input. + +However, the similarities to Redux reducers end at the signature: + +| | reducerAction | Redux/useReducer | +|---|---|---| +| **Purity** | Can have side effects | Must be pure | +| **Async** | Can be async | Must be sync | +| **StrictMode** | Called once | Called twice | +| **Purpose** | Handle async operations | Compute next state | + +The `reducerAction` is called a "reducer" because it *reduces* the previous state and an action into a new state. But unlike traditional reducers, it's designed to perform the async work itself rather than describing what happened. If the term is confusing, you can think of it as an "action handler" or "async state updater" instead. + + --- ## Usage {/*usage*/} -### Using information returned by a form action {/*using-information-returned-by-a-form-action*/} +### Update state based on an Action {/*update-state-based-on-an-action*/} -Call `useActionState` at the top level of your component to access the return value of an action from the last time a form was submitted. +Call `useActionState` at the top level of your component to create state for the result of an Action. -```js [[1, 5, "state"], [2, 5, "formAction"], [3, 5, "action"], [4, 5, "null"], [2, 8, "formAction"]] +```js [[1, 7, "count"], [2, 7, "action"], [3, 7, "isPending"]] import { useActionState } from 'react'; -import { action } from './actions.js'; -function MyComponent() { - const [state, formAction] = useActionState(action, null); +async function increment(prevCount) { // ... +} +function Counter() { + const [count, action, isPending] = useActionState(increment, 0); + + // ... +} +``` + +`useActionState` returns an array with exactly three items: + +1. The current state, initially set to the initial state you provided. +2. The `action` function that lets you trigger the action. +3. The pending state that tells you whether `action` is in progress. + +To trigger the Action, call the `action` function inside an Action prop or [`startTransition`](/reference/react/startTransition). React will call your `reducerAction` with the previous state and argument passed to `action`, and return the new state. + + + +```js src/App.js +import { useActionState, startTransition } from 'react'; + +async function increment(prevCount) { + await new Promise(resolve => setTimeout(resolve, 500)); + return prevCount + 1; +} + +export default function Counter() { + const [count, action, isPending] = useActionState(increment, 0); + + function handleClick() { + startTransition(() => { + action(); + }); + } + return ( -
- {/* ... */} -
+
+

Count: {count}

+ +
); } ``` -`useActionState` returns an array with the following items: +```css +div { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} +``` -1. The current state of the form, which is initially set to the initial state you provided, and after the form is submitted is set to the return value of the action you provided. -2. A new action that you pass to `
` as its `action` prop or call manually within `startTransition`. -3. A pending state that you can utilise while your action is processing. + -When the form is submitted, the action function that you provided will be called. Its return value will become the new current state of the form. -The action that you provide will also receive a new first argument, namely the current state of the form. The first time the form is submitted, this will be the initial state you provided, while with subsequent submissions, it will be the return value from the last time the action was called. The rest of the arguments are the same as if `useActionState` had not been used. -```js [[3, 1, "action"], [1, 1, "currentState"]] -function action(currentState, formData) { - // ... - return 'next state'; + + +#### How is useActionState different from useReducer? {/*useactionstate-vs-usereducer*/} + +`useActionState` and `useReducer` both let you update state based on the previous state, but they serve different purposes: + +| | useActionState | useReducer | +|---|---|---| +| **Designed for** | Async operations with side effects | Pure sync state logic | +| **Function purity** | Can have side effects, async | Must be pure, sync | +| **StrictMode behavior** | Called once | Called twice (to detect impurities) | +| **Pending state** | Built-in `isPending` | Manual tracking | +| **Queuing** | Automatically queues async calls | Immediate sync updates | + +**Use `useReducer`** when you have complex state logic with multiple sub-values or when the next state depends on the previous one in a predictable, synchronous way. The reducer must be pure. + +**Use `useActionState`** when you need to call an async function (like a server request) and track its pending and result state. The `reducerAction` is designed to perform side effects. + +#### Related Hooks {/*related-hooks*/} + +**[`useFormStatus`](/reference/react-dom/hooks/useFormStatus)** reads the submission status of a parent ``. Use it in child components to access `pending`, `data`, `method`, and `action` without prop drilling. Unlike `useActionState`, it doesn't manage state—it only reads form status. + +**`useState` + `useTransition`** is a manual alternative. You can achieve similar behavior by combining these hooks yourself, but `useActionState` bundles the pending state, action queuing, and state updates into a single hook with less boilerplate. + +#### When not to use useActionState {/*when-not-to-use*/} + +- **Simple synchronous state:** Use [`useState`](/reference/react/useState) for state that updates immediately without async operations. +- **Complex synchronous logic:** Use [`useReducer`](/reference/react/useReducer) when you have multiple related state values or complex update logic that doesn't involve async operations. + + + +### Migrating from other patterns {/*migrating-from-other-patterns*/} + +If you have existing code that manually tracks loading and error state, you can simplify it with `useActionState`. + +**Before: Manual state management** + +```js +const [data, setData] = useState(null); +const [isLoading, setIsLoading] = useState(false); +const [error, setError] = useState(null); + +async function handleSubmit(formData) { + setIsLoading(true); + setError(null); + try { + const result = await submitToServer(formData); + setData(result); + } catch (e) { + setError(e.message); + } finally { + setIsLoading(false); + } } ``` - +**After: Using useActionState** + +```js +const [state, action, isPending] = useActionState(async (prev, formData) => { + try { + const result = await submitToServer(formData); + return { data: result, error: null }; + } catch (e) { + return { data: prev.data, error: e.message }; + } +}, { data: null, error: null }); +``` + +The `useActionState` version eliminates the boilerplate of managing `isLoading` manually and ensures correct state updates even when multiple submissions are queued. + +#### When to use data fetching libraries instead {/*when-to-use-data-fetching-libraries*/} + +`useActionState` is designed for **mutations**—actions that change data on the server and need to track pending state. For **data fetching**, consider using dedicated libraries like React Query, SWR, or your framework's data loading primitives. + +Use data fetching libraries when you need: +- **Caching:** Avoid refetching data you already have +- **Background refetching:** Keep data fresh automatically +- **Deduplication:** Prevent duplicate requests for the same data +- **Optimistic updates with rollback:** Complex undo logic on failure + +Use `useActionState` when you need: +- **Sequential execution:** Each action waits for the previous one +- **Form submissions:** Mutations triggered by user input +- **Pending state for mutations:** Show loading state during server updates + + + +#### How action queuing works {/*how-action-queuing-works*/} + +When you call `action` multiple times, React queues them and processes them sequentially in the order they were called. While actions are queued: + +- **No intermediate renders:** React does not re-render between queued actions. Each action receives the state returned by the previous action, and a single render occurs after all queued actions complete. +- **`isPending` stays `true`:** The pending state remains `true` until all queued actions finish processing. +- **Errors cancel the queue:** If an action throws an error, React cancels all remaining queued actions and shows the nearest error boundary. + +This queuing behavior ensures that each action can depend on the result of the previous one, which is essential for sequential operations like form submissions. + + + +--- + +### Combine with useOptimistic for optimistic updates {/*combine-with-useoptimistic*/} -#### Display form errors {/*display-form-errors*/} +For the best user experience, you can combine `useActionState` with [`useOptimistic`](/reference/react/useOptimistic) to show immediate UI feedback while the server request is in progress. The optimistic state displays instantly, and reverts automatically if the action fails. -To display messages such as an error message or toast that's returned by a Server Function, wrap the action in a call to `useActionState`. +This pattern works well for interactions like toggling a like button, where you want the UI to respond immediately: ```js src/App.js -import { useActionState, useState } from "react"; -import { addToCart } from "./actions.js"; +import { useActionState, useOptimistic, startTransition } from 'react'; +import { toggleLikeOnServer } from './actions.js'; + +export default function LikeButton({ initialLiked }) { + const [state, action, isPending] = useActionState( + async (prevState) => { + const result = await toggleLikeOnServer(!prevState.liked); + if (result.error) { + return { liked: prevState.liked, error: result.error }; + } + return { liked: result.liked, error: null }; + }, + { liked: initialLiked ?? false, error: null } + ); + + const [optimisticLiked, setOptimisticLiked] = useOptimistic(state.liked); + + function handleClick() { + startTransition(() => { + setOptimisticLiked(!optimisticLiked); + action(); + }); + } -function AddToCartForm({itemID, itemTitle}) { - const [message, formAction, isPending] = useActionState(addToCart, null); return ( - -

{itemTitle}

- - - {isPending ? "Loading..." : message} - +
+ + {state.error &&

{state.error}

} +
); } +``` + +```js src/actions.js hidden +export async function toggleLikeOnServer(newLiked) { + await new Promise(resolve => setTimeout(resolve, 1000)); + // Simulate occasional server errors + if (Math.random() < 0.3) { + return { error: 'Failed to save. Please try again.' }; + } + return { liked: newLiked }; +} +``` -export default function App() { - return ( - <> - - - - ) +```css +.error { + color: red; } ``` -```js src/actions.js -"use server"; +
+ +When the button is clicked: +1. `setOptimisticLiked` immediately updates the UI to show the new like state +2. `action()` sends the request to the server +3. If the server succeeds, the `state.liked` updates to match the optimistic value +4. If the server fails, the optimistic state automatically reverts to the previous value + +Use this pattern when you want instant feedback but still need to track server results and handle errors gracefully. + +--- -export async function addToCart(prevState, queryData) { - const itemID = queryData.get('itemID'); - if (itemID === "1") { - return "Added to cart"; - } else { - // Add a fake delay to make waiting noticeable. - await new Promise(resolve => { - setTimeout(resolve, 2000); +### Pass arguments to the Action {/*pass-arguments-to-action*/} + +You can pass arguments to the `reducerAction` through the `action` function. The `reducerAction` receives the previous state as its first argument, followed by any arguments you pass to `action`. + + + +```js src/App.js +import { useActionState, startTransition } from 'react'; + +async function changeCount(prevCount, amount) { + await new Promise(resolve => setTimeout(resolve, 500)); + return prevCount + amount; +} + +export default function Counter() { + const [count, action, isPending] = useActionState(changeCount, 0); + + function handleIncrement() { + startTransition(() => { + action(1); + }); + } + + function handleDecrement() { + startTransition(() => { + action(-1); }); - return "Couldn't add to cart: the item is sold out."; } + + return ( +
+

Count: {count}

+ + + {isPending &&

Updating...

} +
+ ); } ``` -```css src/styles.css hidden -form { - border: solid 1px black; - margin-bottom: 24px; - padding: 12px +```css +div { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; } -form button { - margin-right: 12px; +div > div { + display: flex; + gap: 8px; } ``` +
- +--- + +### Handle errors and display structured state {/*handle-errors-structured-state*/} -#### Display structured information after submitting a form {/*display-structured-information-after-submitting-a-form*/} +The state can be any **serializable value** (a value that can be converted to JSON), including objects. This is useful for tracking success/error states and returning additional information from the action. -The return value from a Server Function can be any serializable value. For example, it could be an object that includes a boolean indicating whether the action was successful, an error message, or updated information. +When making HTTP requests, you should handle different types of errors: +- **Network errors**: When the request fails to reach the server (e.g., no internet connection) +- **HTTP errors**: When the server responds with a 4xx or 5xx status code +- **Validation errors**: When the server returns field-specific validation errors ```js src/App.js -import { useActionState, useState } from "react"; -import { addToCart } from "./actions.js"; +import { useState, useActionState, startTransition } from 'react'; -function AddToCartForm({itemID, itemTitle}) { - const [formState, formAction] = useActionState(addToCart, {}); - return ( -
-

{itemTitle}

- - - {formState?.success && -
- Added to cart! Your cart now has {formState.cartSize} items. -
- } - {formState?.success === false && -
- Failed to add to cart: {formState.message} -
+async function submitData(prevState, formData) { + const name = formData.name; + + try { + const response = await fetch('/api/submit', { + method: 'POST', + body: JSON.stringify({ name }), + headers: { 'Content-Type': 'application/json' }, + }); + + if (!response.ok) { + // Handle HTTP errors + if (response.status === 400) { + // Validation errors from server + const data = await response.json(); + return { success: false, fieldErrors: data.errors }; } -
- ); + // Server errors (5xx) or other client errors (4xx) + return { success: false, error: 'Server error. Please try again.' }; + } + + const data = await response.json(); + return { success: true, message: `Saved "${data.name}"` }; + } catch (e) { + // Handle network errors (fetch throws when network fails) + return { success: false, error: 'Network error. Check your connection.' }; + } } -export default function App() { +export default function DataForm() { + const [name, setName] = useState(''); + const [result, action, isPending] = useActionState(submitData, null); + + function handleSubmit() { + startTransition(() => { + action({ name }); + }); + } + return ( - <> - - - - ) +
+ setName(e.target.value)} + placeholder="Enter name" + disabled={isPending} + /> + + {result?.success &&

{result.message}

} + {result?.fieldErrors?.name && ( +

{result.fieldErrors.name}

+ )} + {result?.error &&

{result.error}

} +
+ ); } ``` -```js src/actions.js -"use server"; +```js src/server.js hidden +// This simulates a server API endpoint. +// In a real app, this would be your backend. + +export async function handleRequest(name) { + await new Promise(resolve => setTimeout(resolve, 1000)); -export async function addToCart(prevState, queryData) { - const itemID = queryData.get('itemID'); - if (itemID === "1") { + // Simulate validation errors (400) + if (!name || name.trim() === '') { return { - success: true, - cartSize: 12, + status: 400, + body: { errors: { name: 'Name is required' } } }; - } else { + } + + if (name.length < 3) { return { - success: false, - message: "The item is sold out.", + status: 400, + body: { errors: { name: 'Name must be at least 3 characters' } } }; } + + // Simulate occasional server errors (500) + if (Math.random() < 0.2) { + return { status: 500, body: { message: 'Internal server error' } }; + } + + // Success + return { status: 200, body: { name } }; } ``` -```css src/styles.css hidden +```js src/index.js hidden +import { handleRequest } from './server.js'; + +// Mock the fetch API to simulate server responses +const originalFetch = window.fetch; +window.fetch = async (url, options) => { + if (url === '/api/submit') { + const body = JSON.parse(options.body); + const result = await handleRequest(body.name); + return { + ok: result.status >= 200 && result.status < 300, + status: result.status, + json: async () => result.body + }; + } + return originalFetch(url, options); +}; + +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.js'; + +const root = createRoot(document.getElementById('root')); +root.render( + + + +); +``` + +```css +div { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 300px; +} + +.success { + color: green; +} + +.error { + color: red; +} +``` + +
+ +The example above demonstrates a complete error handling pattern: + +1. **Try/catch** wraps the entire request to catch network failures +2. **`response.ok`** checks for HTTP errors (status 200-299 is ok, anything else is not) +3. **Status code checking** distinguishes validation errors (400) from server errors (5xx) +4. **Structured error state** separates field-specific errors (`fieldErrors`) from general errors (`error`) + +--- + +### Queue multiple actions {/*queue-multiple-actions*/} + +When you call `action` multiple times, React queues and executes them sequentially. Each `action` receives the state returned by the previous `action`. The component does not re-render between queued actions—React waits until all queued actions complete before updating the UI. + + + +```js src/App.js +import { useActionState, startTransition } from 'react'; + +let nextId = 0; + +async function addItem(prevItems, name) { + await new Promise(resolve => setTimeout(resolve, 300)); + return [...prevItems, { id: nextId++, name }]; +} + +export default function ShoppingList() { + const [items, action, isPending] = useActionState(addItem, []); + + function handleAddMultiple() { + startTransition(() => { + action('Apples'); + action('Bananas'); + action('Oranges'); + }); + } + + return ( +
+ +
    + {items.map((item) => ( +
  • {item.name}
  • + ))} +
+
+ ); +} +``` + +```css +div { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} + +ul { + margin: 0; + padding-left: 20px; +} +``` + +
+ + + +If the `reducerAction` throws an error, React cancels all remaining queued `action` calls and shows the nearest error boundary. To prevent this, catch errors within your `reducerAction` and return an error state instead of throwing. + + + +--- + +### Using action state in action props {/*using-action-state-in-action-props*/} + +When you pass the `action` function to a component that exposes an [Action prop](/reference/react/useTransition#exposing-action-props-from-components), you don't need to wrap the call in `startTransition` yourself. The component handles the transition internally. + +This example shows an `App` that uses `useActionState` for the count and passes `action` to `SubmitButton`'s `action` prop: + + + +```js src/App.js active +import { useActionState } from 'react'; +import SubmitButton from './SubmitButton'; +import { incrementOnServer } from './actions.js'; + +async function increment(count) { + await incrementOnServer(); + return count + 1; +} + +export default function App() { + const [count, action, isPending] = useActionState(increment, 0); + + return ( +
+ + Increment + +

Count: {count}

+
+ ); +} +``` + +```js src/SubmitButton.js +import { startTransition } from 'react'; + +export default function SubmitButton({ action, isPending, children }) { + return ( + + ); +} +``` + +```js src/actions.js hidden +export async function incrementOnServer() { + await new Promise(resolve => setTimeout(resolve, 1000)); +} +``` + +```css +div { + display: flex; + flex-direction: column; + gap: 8px; + align-items: flex-start; +} +``` + +
+ +The `SubmitButton` component wraps the `action` call in `startTransition`, so `isPending` updates correctly. This pattern lets you build reusable button components that handle transitions internally. + +--- + +### Use with a form {/*use-with-a-form*/} + +The `action` function can be passed as the `action` prop to a form. When used this way, React automatically wraps the submission in a transition, so you don't need to call `startTransition` yourself. The `reducerAction` receives the previous state and the submitted `FormData`. + + + +```js src/App.js +import { useState, startTransition } from 'react'; +import EditName from './EditName'; + +export default function App() { + const [name, setName] = useState('Taylor'); + + function handleSubmit(newName) { + startTransition(() => { + setName(newName); + }); + } + + return ; +} +``` + +```js src/EditName.js active +import { useActionState } from 'react'; +import { updateName } from './actions.js'; + +export default function EditName({ name, onSubmit }) { + const [state, formAction, isPending] = useActionState(submitAction, null); + + async function submitAction(prevState, formData) { + const newName = formData.get('name'); + const result = await updateName(newName); + if (result.error) { + return result; + } + onSubmit(result.name); + return null; + } + + return ( +
+

Your name is: {name}

+ + + {state?.error &&

{state.error}

} +
+ ); +} +``` + +```js src/actions.js hidden +export async function updateName(name) { + await new Promise(resolve => setTimeout(resolve, 1000)); + if (!name || name.trim() === '') { + return { error: 'Name cannot be empty' }; + } + return { name }; +} +``` + +```css form { - border: solid 1px black; - margin-bottom: 24px; - padding: 12px + display: flex; + flex-direction: column; + gap: 8px; + max-width: 300px; } -form button { - margin-right: 12px; +label { + display: flex; + flex-direction: column; + gap: 4px; +} + +.error { + color: red; } ``` +
- +In this example, when the user submits the form, `useActionState` calls the `reducerAction` with the form data. The `reducerAction` validates the name, calls the server, and either returns an error state or calls `onSubmit` to update the parent. -
+When used with a [Server Function](/reference/rsc/server-functions), `useActionState` allows the server's response to be shown before hydration (when React attaches to server-rendered HTML) completes. You can also use the optional `permalink` parameter for progressive enhancement (allowing the form to work before JavaScript loads) on pages with dynamic content. + +--- ## Troubleshooting {/*troubleshooting*/} -### My action can no longer read the submitted form data {/*my-action-can-no-longer-read-the-submitted-form-data*/} +### My action can no longer read the submitted form data {/*action-cant-read-form-data*/} + +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 +// Without useActionState +function action(formData) { + const name = formData.get('name'); +} + +// With useActionState +function action(prevState, formData) { + const name = formData.get('name'); +} +``` + +--- + +### My `isPending` flag is not updating {/*ispending-not-updating*/} + +If you're calling the action manually (not through a form's `action` prop), make sure you wrap the call in [`startTransition`](/reference/react/startTransition): + +```js +import { useActionState, startTransition } from 'react'; + +function MyComponent() { + const [state, action, isPending] = useActionState(myAction, null); + + function handleClick() { + // ✅ Correct: wrap in startTransition + startTransition(() => { + action(); + }); + } + + // ... +} +``` -When you wrap an action with `useActionState`, it gets an extra argument *as its first argument*. The submitted form data is therefore its *second* argument instead of its first as it would usually be. The new first argument that gets added is the current state of the form. +When the action is passed to a form's `action` prop or a button's `formAction` prop, React automatically wraps it in a transition. + +--- + +### I'm getting an error: "Cannot update form state while rendering" {/*cannot-update-during-render*/} + +You cannot call `action` during render. This causes an infinite loop because calling `action` schedules a state update, which triggers a re-render, which calls `action` again. ```js -function action(currentState, formData) { +function MyComponent() { + const [state, action, isPending] = useActionState(myAction, null); + + // ❌ Wrong: calling action during render + action(); + // ... } ``` + +Only call `action` in response to user events (like form submissions or button clicks) or in Effects. + +--- + +### My actions are being skipped {/*actions-skipped*/} + +If you call `action` multiple times and some of them don't run, it may be because an earlier `action` call threw an error. When an `reducerAction` throws, React skips all subsequently queued `action` calls. + +To handle this, catch errors within your `reducerAction` and return an error state instead of throwing: + +```js +async function myReducerAction(prevState, data) { + try { + const result = await submitData(data); + return { success: true, data: result }; + } catch (error) { + // ✅ Return error state instead of throwing + return { success: false, error: error.message }; + } +} +``` + +--- + +### I want to reset the state {/*reset-state*/} + +`useActionState` doesn't provide a built-in reset function. To reset the state, you can design your `reducerAction` to handle a reset signal: + +```js +const initialState = { name: '', error: null }; + +async function formAction(prevState, payload) { + // Handle reset + if (payload === null) { + return initialState; + } + // Normal action logic + const result = await submitData(payload); + return result; +} + +function MyComponent() { + const [state, action, isPending] = useActionState(formAction, initialState); + + function handleReset() { + startTransition(() => { + action(null); // Pass null to trigger reset + }); + } + + // ... +} +``` + +Alternatively, you can add a `key` prop to the component using `useActionState` to force it to remount with fresh state. From 3273a5998e552e52965c2ca03f403a18d016d837 Mon Sep 17 00:00:00 2001 From: Rick Hanlon Date: Mon, 2 Feb 2026 14:40:29 -0500 Subject: [PATCH 2/2] Human edits --- src/content/reference/react/useActionState.md | 1286 +++++++++++------ 1 file changed, 832 insertions(+), 454 deletions(-) diff --git a/src/content/reference/react/useActionState.md b/src/content/reference/react/useActionState.md index 03cb82e6e4e..3c961c9d23e 100644 --- a/src/content/reference/react/useActionState.md +++ b/src/content/reference/react/useActionState.md @@ -32,7 +32,6 @@ function reducerAction(state, action) { function MyComponent() { const [state, action, isPending] = useActionState(reducerAction, {quantity: 1}); // ... - } ``` @@ -41,8 +40,10 @@ function MyComponent() { #### Parameters {/*parameters*/} * `reducerAction`: The function to be called when the Action is triggered. When called, it receives the previous state (initially the `initialState` you provided, then its previous return value) as its first argument, followed by the arguments passed to the `action`. -* `initialState`: The value you want the state to be initially. It can be any serializable value (a value that can be converted to JSON). React ignores this argument after invoking the action for the first time. -* **optional** `permalink`: A string containing the unique page URL that this form modifies. For use on pages with [React Server Components](/reference/rsc/server-components) with progressive enhancement (allowing the form to work before JavaScript loads): if `reducerAction` is a [Server Function](/reference/rsc/server-functions) and the form is submitted before the JavaScript bundle loads, the browser will navigate to the specified permalink URL rather than the current page's URL. +* `initialState`: The value you want the state to be initially. React ignores this argument after invoking the action for the first time. +* **optional** `permalink`: A string containing the unique page URL that this form modifies. + * For use on pages with [React Server Components](/reference/rsc/server-components) with progressive enhancement. + * If `reducerAction` is a [Server Function](/reference/rsc/server-functions) and the form is submitted before the JavaScript bundle loads, the browser will navigate to the specified permalink URL rather than the current page's URL. #### Returns {/*returns*/} @@ -54,13 +55,12 @@ function MyComponent() { #### Caveats {/*caveats*/} -* React queues and executes multiple calls to `action` sequentially so the `reducerAction` can be called with the previous result of the Action. This is by design: in order to trigger side effects based on the previous result, you have to wait for the previous `action` to finish to know its result. * `useActionState` is a Hook, so it must be called **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 `action` sequentially, allowing each `reducerAction` to use the result of the previous Action. * The `action` function has a stable identity, so you will often see it omitted from Effect dependencies, but including it will not cause the Effect to fire. If the linter lets you omit a dependency without errors, it is safe to do. [Learn more about removing Effect dependencies.](/learn/removing-effect-dependencies#move-dynamic-objects-and-functions-inside-your-effect) * When using the `permalink` option, ensure the same form component is rendered on the destination page (including the same `reducerAction` and `permalink`) so React knows how to pass the state through. Once the page becomes interactive, this parameter has no effect. -* Calling `action` during render throws an error: "Cannot update form state while rendering." -* If `action` throws an error, React cancels all subsequent queued actions, and shows the nearest [error boundary](/reference/react/Component#catching-rendering-errors-with-an-error-boundary). To dispatch new actions after an error boundary catches an error, the component must remount (the action queue is cleared when the error boundary renders). -* If the component unmounts while `isPending` is true, any pending actions will complete but their state updates are silently ignored. React will not show an "update on unmounted component" warning. +* When using Server Functions, `initialState` needs to be serializable (values like plain objects, arrays, strings, and numbers). +* If `action` throws an error, React cancels all queued actions and shows the nearest [Error Boundary](/reference/react/Component#catching-rendering-errors-with-an-error-boundary). @@ -68,11 +68,13 @@ When calling the `action` function, you must wrap the call in [`startTransition` +--- + ### `reducerAction` function {/*reduceraction*/} -The `reducerAction` function passed to `useActionState` receives the previous state and returns a new state, similar to the `reducer` passed to `useReducer`. However, the similarity ends there—`reducerAction` is designed for async operations with side effects, not pure synchronous state logic. +The `reducerAction` function passed to `useActionState` receives the previous state and returns a new state. -Unlike the `reducer` in `useReducer`, the `reducerAction` can be async and perform side effects: +Unlike reducers in `useReducer`, the `reducerAction` can be async and perform side effects: ```js async function reducerAction(previousState, update) { @@ -86,7 +88,7 @@ function MyCart({initialCart}) { } ``` -For each call to `action`, React calls the `reducerAction` to perform side effects and compute the result of that Action. If the `action` is called multiple times, React queues and executes them in order so the result of the previous call is available for current call. +Each time you call `action`, React calls the `reducerAction` to perform side effects and compute the result of that Action. If the `action` is called multiple times, React queues and executes them in order so the result of the previous call is available for current call. #### Parameters {/*reduceraction-parameters*/} @@ -100,30 +102,18 @@ For each call to `action`, React calls the `reducerAction` to perform side effec #### Caveats {/*reduceraction-caveats*/} -* Unlike `useReducer`, the `reducerAction` for `useActionState` is not invoked twice in StrictMode. This is because the `reducer` for `useReducer` must be pure (without side effects), but the `reducerAction` for `useActionState` is designed to allow side-effects. However, the component using `useActionState` may still double-render in StrictMode. Calling `action` in event handlers is fine (the handler only runs once), but be careful with calling `action` in Effects—the Effect may run twice in development. - - - -Despite the "reducer" in its name, `reducerAction` is not a pure reducer like those used with `useReducer` or Redux. It can be async and perform side effects. See ["Why is it called reducerAction?"](#why-reduceraction-name) below for more details. - - +* `reducerAction` is not invoked twice in StrictMode since `reducerAction` is designed to allow side effects. -#### Why is it called reducerAction? {/*why-reduceraction-name*/} +#### Why is it called `reducerAction`? {/*why-is-it-called-reduceraction*/} -The name comes from its function signature: `(previousState, payload) => newState`. This matches the classic reducer pattern where the new state is computed from the previous state plus some input. +The function passed to `useActionState` is called a *reducer action* because: -However, the similarities to Redux reducers end at the signature: +- It *reduces* the previous state into a new state, like `useReducer`. +- It's called inside a Transition and can perform side effects, like an Action. -| | reducerAction | Redux/useReducer | -|---|---|---| -| **Purity** | Can have side effects | Must be pure | -| **Async** | Can be async | Must be sync | -| **StrictMode** | Called once | Called twice | -| **Purpose** | Handle async operations | Compute next state | - -The `reducerAction` is called a "reducer" because it *reduces* the previous state and an action into a new state. But unlike traditional reducers, it's designed to perform the async work itself rather than describing what happened. If the term is confusing, you can think of it as an "action handler" or "async state updater" instead. +Conceptually, `useActionState` is like `useReducer`, but you can do side effects in the reducer. @@ -131,7 +121,7 @@ The `reducerAction` is called a "reducer" because it *reduces* the previous stat ## Usage {/*usage*/} -### Update state based on an Action {/*update-state-based-on-an-action*/} +### Adding state to an Action {/*adding-state-to-an-action*/} Call `useActionState` at the top level of your component to create state for the result of an Action. @@ -151,23 +141,24 @@ function Counter() { `useActionState` returns an array with exactly three items: 1. The current state, initially set to the initial state you provided. -2. The `action` function that lets you trigger the action. +2. The `action` function that lets you trigger the `reducerAction`. 3. The pending state that tells you whether `action` is in progress. -To trigger the Action, call the `action` function inside an Action prop or [`startTransition`](/reference/react/startTransition). React will call your `reducerAction` with the previous state and argument passed to `action`, and return the new state. +To trigger the Action, call the `action` function inside an [Action](/reference/react/useTransition#functions-called-in-starttransition-are-called-actions). React will call your `reducerAction` with the previous state and argument passed to `action`, and return the new state. ```js src/App.js import { useActionState, startTransition } from 'react'; +import { addToCart } from './api'; +import Total from './Total'; -async function increment(prevCount) { - await new Promise(resolve => setTimeout(resolve, 500)); - return prevCount + 1; +async function addTicket(prevCount) { + return await addToCart(prevCount); } -export default function Counter() { - const [count, action, isPending] = useActionState(increment, 0); +export default function Checkout() { + const [count, action, isPending] = useActionState(addTicket, 0); function handleClick() { startTransition(() => { @@ -176,655 +167,1042 @@ export default function Counter() { } return ( -
-

Count: {count}

- +
+

Checkout

+
+ Eras Tour Tickets + {isPending && '🌀 '}Qty: {count} +
+
+ +
+
+ +
+ ); +} +``` + +```js src/Total.js +const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, +}); + +export default function Total({quantity, isPending}) { + return ( +
+ Total + {isPending ? '🌀 Updating...' : formatter.format(quantity * 9999)}
); } ``` +```js src/api.js hidden +export async function addToCart(count) { + await new Promise(resolve => setTimeout(resolve, 1000)); + return count + 1; +} + +export async function removeFromCart(count) { + await new Promise(resolve => setTimeout(resolve, 1000)); + return Math.max(0, count - 1); +} +``` + ```css -div { +.checkout { display: flex; flex-direction: column; - gap: 8px; - align-items: flex-start; + gap: 12px; + padding: 16px; + border: 1px solid #ccc; + border-radius: 8px; + font-family: system-ui; +} + +.checkout h2 { + margin: 0 0 8px 0; +} + +.row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.total { + font-weight: bold; +} + +hr { + width: 100%; + border: none; + border-top: 1px solid #ccc; + margin: 4px 0; +} + +button { + padding: 8px 16px; + cursor: pointer; } ``` - +Every time you click "Add Ticket," React queues a call to `addTicket`. React shows the pending state until all of the tickets are added, and then re-renders with the final state. -#### How is useActionState different from useReducer? {/*useactionstate-vs-usereducer*/} +#### How `useActionState` queuing works {/*how-useactionstate-queuing-works*/} -`useActionState` and `useReducer` both let you update state based on the previous state, but they serve different purposes: +Try clicking "Add Ticket" multiple times. Every time you click, a new `addTicket` is queued. Since there's an artificial 1 second delay, that means 4 clicks will take ~4 seconds to complete. -| | useActionState | useReducer | -|---|---|---| -| **Designed for** | Async operations with side effects | Pure sync state logic | -| **Function purity** | Can have side effects, async | Must be pure, sync | -| **StrictMode behavior** | Called once | Called twice (to detect impurities) | -| **Pending state** | Built-in `isPending` | Manual tracking | -| **Queuing** | Automatically queues async calls | Immediate sync updates | +**This is intentional in the design of `useActionState`.** -**Use `useReducer`** when you have complex state logic with multiple sub-values or when the next state depends on the previous one in a predictable, synchronous way. The reducer must be pure. +We have to wait for the previous result of `addTicket` in order to pass the `prevCount` to the next call to `addTicket`. That means React has to wait for the previous Action to finish before calling the next Action. -**Use `useActionState`** when you need to call an async function (like a server request) and track its pending and result state. The `reducerAction` is designed to perform side effects. +You can typically solve this by [using with useOptimistic](/reference/react/useActionState#using-with-useoptimistic) but for more complex cases you may want to consider [cancelling queued actions](#cancelling-queued-actions) or not using `useActionState`. -#### Related Hooks {/*related-hooks*/} + -**[`useFormStatus`](/reference/react-dom/hooks/useFormStatus)** reads the submission status of a parent `
`. Use it in child components to access `pending`, `data`, `method`, and `action` without prop drilling. Unlike `useActionState`, it doesn't manage state—it only reads form status. +### Using multiple Action types {/*using-multiple-action-types*/} -**`useState` + `useTransition`** is a manual alternative. You can achieve similar behavior by combining these hooks yourself, but `useActionState` bundles the pending state, action queuing, and state updates into a single hook with less boilerplate. +To handle multiple types, you can pass an argument to `action`. -#### When not to use useActionState {/*when-not-to-use*/} +By convention, it is common to write it as a switch statement. For each case in the switch, calculate and return some next state. The argument can have any shape, but it is common to pass objects with a `type` property identifying the action. -- **Simple synchronous state:** Use [`useState`](/reference/react/useState) for state that updates immediately without async operations. -- **Complex synchronous logic:** Use [`useReducer`](/reference/react/useReducer) when you have multiple related state values or complex update logic that doesn't involve async operations. + - +```js src/App.js +import { useActionState, startTransition } from 'react'; +import { addToCart, removeFromCart } from './api'; +import Total from './Total'; -### Migrating from other patterns {/*migrating-from-other-patterns*/} +export default function Checkout() { + const [count, action, isPending] = useActionState(updateCart, 0); -If you have existing code that manually tracks loading and error state, you can simplify it with `useActionState`. + function handleAdd() { + startTransition(() => { + action({type: 'ADD'}); + }); + } -**Before: Manual state management** + function handleRemove() { + startTransition(() => { + action({type: 'REMOVE'}); + }); + } -```js -const [data, setData] = useState(null); -const [isLoading, setIsLoading] = useState(false); -const [error, setError] = useState(null); + return ( +
+

Checkout

+
+ Eras Tour Tickets + + {isPending && '🌀'} + {count} + + + + + +
+
+ +
+ ); +} -async function handleSubmit(formData) { - setIsLoading(true); - setError(null); - try { - const result = await submitToServer(formData); - setData(result); - } catch (e) { - setError(e.message); - } finally { - setIsLoading(false); +async function updateCart(prevCount, update) { + switch (update.type) { + case 'ADD': { + return await addToCart(prevCount); + } + case 'REMOVE': { + return await removeFromCart(prevCount); + } } + return prevCount; } ``` -**After: Using useActionState** +```js src/Total.js +const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, +}); -```js -const [state, action, isPending] = useActionState(async (prev, formData) => { - try { - const result = await submitToServer(formData); - return { data: result, error: null }; - } catch (e) { - return { data: prev.data, error: e.message }; - } -}, { data: null, error: null }); +export default function Total({quantity}) { + return ( +
+ Total + {formatter.format(quantity * 9999)} +
+ ); +} ``` -The `useActionState` version eliminates the boilerplate of managing `isLoading` manually and ensures correct state updates even when multiple submissions are queued. +```js src/api.js hidden +export async function addToCart(count) { + await new Promise(resolve => setTimeout(resolve, 1000)); + return count + 1; +} -#### When to use data fetching libraries instead {/*when-to-use-data-fetching-libraries*/} +export async function removeFromCart(count) { + await new Promise(resolve => setTimeout(resolve, 1000)); + return Math.max(0, count - 1); +} +``` -`useActionState` is designed for **mutations**—actions that change data on the server and need to track pending state. For **data fetching**, consider using dedicated libraries like React Query, SWR, or your framework's data loading primitives. +```css +.checkout { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border: 1px solid #ccc; + border-radius: 8px; + font-family: system-ui; +} -Use data fetching libraries when you need: -- **Caching:** Avoid refetching data you already have -- **Background refetching:** Keep data fresh automatically -- **Deduplication:** Prevent duplicate requests for the same data -- **Optimistic updates with rollback:** Complex undo logic on failure +.checkout h2 { + margin: 0 0 8px 0; +} -Use `useActionState` when you need: -- **Sequential execution:** Each action waits for the previous one -- **Form submissions:** Mutations triggered by user input -- **Pending state for mutations:** Show loading state during server updates +.row { + display: flex; + justify-content: space-between; + align-items: center; +} - +.stepper { + display: flex; + align-items: center; + gap: 8px; +} -#### How action queuing works {/*how-action-queuing-works*/} +.qty { + min-width: 20px; + text-align: center; +} -When you call `action` multiple times, React queues them and processes them sequentially in the order they were called. While actions are queued: +.buttons { + display: flex; + flex-direction: column; + gap: 2px; +} -- **No intermediate renders:** React does not re-render between queued actions. Each action receives the state returned by the previous action, and a single render occurs after all queued actions complete. -- **`isPending` stays `true`:** The pending state remains `true` until all queued actions finish processing. -- **Errors cancel the queue:** If an action throws an error, React cancels all remaining queued actions and shows the nearest error boundary. +.buttons button { + padding: 0 8px; + font-size: 10px; + line-height: 1.2; + cursor: pointer; +} -This queuing behavior ensures that each action can depend on the result of the previous one, which is essential for sequential operations like form submissions. +.pending { + width: 20px; + text-align: center; +} - +.total { + font-weight: bold; +} + +hr { + width: 100%; + border: none; + border-top: 1px solid #ccc; + margin: 4px 0; +} +``` + +
+ + + +#### How is `useActionState` different from `useReducer`? {/*useactionstate-vs-usereducer*/} + +You might notice this example looks a lot like `useReducer`, but they serve different purposes: + +- **Use `useReducer`** to manage state of your UI. The reducer must be pure. + +- **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. + + --- -### Combine with useOptimistic for optimistic updates {/*combine-with-useoptimistic*/} +### Using with Action props {/*using-with-action-props*/} + +When you pass the `action` function to a component that exposes an [Action prop](/reference/react/useTransition#exposing-action-props-from-components), you don't need to wrap the call in `startTransition` yourself. The component handles the transition internally. -For the best user experience, you can combine `useActionState` with [`useOptimistic`](/reference/react/useOptimistic) to show immediate UI feedback while the server request is in progress. The optimistic state displays instantly, and reverts automatically if the action fails. +This example shows using the `increaseAction` and `decreaseAction` props of a QuantityStepper component: -This pattern works well for interactions like toggling a like button, where you want the UI to respond immediately: ```js src/App.js -import { useActionState, useOptimistic, startTransition } from 'react'; -import { toggleLikeOnServer } from './actions.js'; - -export default function LikeButton({ initialLiked }) { - const [state, action, isPending] = useActionState( - async (prevState) => { - const result = await toggleLikeOnServer(!prevState.liked); - if (result.error) { - return { liked: prevState.liked, error: result.error }; - } - return { liked: result.liked, error: null }; - }, - { liked: initialLiked ?? false, error: null } - ); +import { useActionState } from 'react'; +import { addToCart, removeFromCart } from './api'; +import QuantityStepper from './QuantityStepper'; +import Total from './Total'; - const [optimisticLiked, setOptimisticLiked] = useOptimistic(state.liked); +export default function Checkout() { + const [count, action, isPending] = useActionState(updateCart, 0); - function handleClick() { - startTransition(() => { - setOptimisticLiked(!optimisticLiked); - action(); - }); + function addAction() { + action({type: 'ADD'}); + } + + function removeAction() { + action({type: 'REMOVE'}); } return ( -
- - {state.error &&

{state.error}

} +
+

Checkout

+
+ Eras Tour Tickets + +
+
+
); } -``` -```js src/actions.js hidden -export async function toggleLikeOnServer(newLiked) { - await new Promise(resolve => setTimeout(resolve, 1000)); - // Simulate occasional server errors - if (Math.random() < 0.3) { - return { error: 'Failed to save. Please try again.' }; +async function updateCart(prevCount, update) { + switch (update.type) { + case 'ADD': { + return await addToCart(prevCount); + } + case 'REMOVE': { + return await removeFromCart(prevCount); + } } - return { liked: newLiked }; -} -``` - -```css -.error { - color: red; + return prevCount; } ``` - - -When the button is clicked: -1. `setOptimisticLiked` immediately updates the UI to show the new like state -2. `action()` sends the request to the server -3. If the server succeeds, the `state.liked` updates to match the optimistic value -4. If the server fails, the optimistic state automatically reverts to the previous value - -Use this pattern when you want instant feedback but still need to track server results and handle errors gracefully. - ---- - -### Pass arguments to the Action {/*pass-arguments-to-action*/} - -You can pass arguments to the `reducerAction` through the `action` function. The `reducerAction` receives the previous state as its first argument, followed by any arguments you pass to `action`. - - - -```js src/App.js -import { useActionState, startTransition } from 'react'; - -async function changeCount(prevCount, amount) { - await new Promise(resolve => setTimeout(resolve, 500)); - return prevCount + amount; -} +```js src/QuantityStepper.js +import { useTransition } from 'react'; -export default function Counter() { - const [count, action, isPending] = useActionState(changeCount, 0); +export default function QuantityStepper({value, increaseAction, decreaseAction}) { + const [isPending, startTransition] = useTransition(); - function handleIncrement() { - startTransition(() => { - action(1); + function handleIncrease() { + startTransition(async () => { + await increaseAction(); }); } - function handleDecrement() { - startTransition(() => { - action(-1); + function handleDecrease() { + startTransition(async () => { + await decreaseAction(); }); } return ( -
-

Count: {count}

- - - {isPending &&

Updating...

} + + {isPending && '🌀'} + {value} + + + + + + ); +} +``` + +```js src/Total.js +const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, +}); + +export default function Total({quantity}) { + return ( +
+ Total + {formatter.format(quantity * 9999)}
); } ``` +```js src/api.js hidden +export async function addToCart(count) { + await new Promise(resolve => setTimeout(resolve, 1000)); + return count + 1; +} + +export async function removeFromCart(count) { + await new Promise(resolve => setTimeout(resolve, 1000)); + return Math.max(0, count - 1); +} +``` + ```css -div { +.checkout { display: flex; flex-direction: column; - gap: 8px; - align-items: flex-start; + gap: 12px; + padding: 16px; + border: 1px solid #ccc; + border-radius: 8px; + font-family: system-ui; } -div > div { +.checkout h2 { + margin: 0 0 8px 0; +} + +.row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.stepper { display: flex; + align-items: center; gap: 8px; } + +.qty { + min-width: 20px; + text-align: center; +} + +.buttons { + display: flex; + flex-direction: column; + gap: 2px; +} + +.buttons button { + padding: 0 8px; + font-size: 10px; + line-height: 1.2; + cursor: pointer; +} + +.pending { + width: 20px; + text-align: center; +} + +.total { + font-weight: bold; +} + +hr { + width: 100%; + border: none; + border-top: 1px solid #ccc; + margin: 4px 0; +} ``` +Since `` has built-in support for pending state, the loading indicator is shown automatically. + --- -### Handle errors and display structured state {/*handle-errors-structured-state*/} +### Using with `useOptimistic` {/*using-with-useoptimistic*/} -The state can be any **serializable value** (a value that can be converted to JSON), including objects. This is useful for tracking success/error states and returning additional information from the action. +You can combine `useActionState` with [`useOptimistic`](/reference/react/useOptimistic) to show immediate UI feedback: -When making HTTP requests, you should handle different types of errors: -- **Network errors**: When the request fails to reach the server (e.g., no internet connection) -- **HTTP errors**: When the server responds with a 4xx or 5xx status code -- **Validation errors**: When the server returns field-specific validation errors ```js src/App.js -import { useState, useActionState, startTransition } from 'react'; - -async function submitData(prevState, formData) { - const name = formData.name; - - try { - const response = await fetch('/api/submit', { - method: 'POST', - body: JSON.stringify({ name }), - headers: { 'Content-Type': 'application/json' }, - }); - - if (!response.ok) { - // Handle HTTP errors - if (response.status === 400) { - // Validation errors from server - const data = await response.json(); - return { success: false, fieldErrors: data.errors }; - } - // Server errors (5xx) or other client errors (4xx) - return { success: false, error: 'Server error. Please try again.' }; +import { useActionState, useOptimistic } from 'react'; +import { addToCart, removeFromCart } from './api'; +import QuantityStepper from './QuantityStepper'; +import Total from './Total'; + +async function updateCart(prevCount, update) { + switch (update.type) { + case 'ADD': { + return await addToCart(prevCount); + } + case 'REMOVE': { + return await removeFromCart(prevCount); } - - const data = await response.json(); - return { success: true, message: `Saved "${data.name}"` }; - } catch (e) { - // Handle network errors (fetch throws when network fails) - return { success: false, error: 'Network error. Check your connection.' }; } + return prevCount; } -export default function DataForm() { - const [name, setName] = useState(''); - const [result, action, isPending] = useActionState(submitData, null); +export default function Checkout() { + const [count, action, isPending] = useActionState(updateCart, 0); + const [optimisticCount, setOptimisticCount] = useOptimistic(count); - function handleSubmit() { - startTransition(() => { - action({ name }); - }); + async function addAction() { + setOptimisticCount(c => c + 1); + await action({type: 'ADD'}); + } + + async function removeAction() { + setOptimisticCount(c => Math.max(0, c - 1)); + await action({type: 'REMOVE'}); } return ( -
- setName(e.target.value)} - placeholder="Enter name" - disabled={isPending} - /> - - {result?.success &&

{result.message}

} - {result?.fieldErrors?.name && ( -

{result.fieldErrors.name}

- )} - {result?.error &&

{result.error}

} +
+

Checkout

+
+ Eras Tour Tickets + +
+
+
); } ``` -```js src/server.js hidden -// This simulates a server API endpoint. -// In a real app, this would be your backend. +```js src/QuantityStepper.js +import { useTransition } from 'react'; -export async function handleRequest(name) { - await new Promise(resolve => setTimeout(resolve, 1000)); - - // Simulate validation errors (400) - if (!name || name.trim() === '') { - return { - status: 400, - body: { errors: { name: 'Name is required' } } - }; - } +export default function QuantityStepper({value, increaseAction, decreaseAction}) { + const [isPending, startTransition] = useTransition(); - if (name.length < 3) { - return { - status: 400, - body: { errors: { name: 'Name must be at least 3 characters' } } - }; + function handleIncrease() { + startTransition(async () => { + await increaseAction(); + }); } - // Simulate occasional server errors (500) - if (Math.random() < 0.2) { - return { status: 500, body: { message: 'Internal server error' } }; + function handleDecrease() { + startTransition(async () => { + await decreaseAction(); + }); } - // Success - return { status: 200, body: { name } }; + return ( + + {isPending && '🌀'} + {value} + + + + + + ); } ``` -```js src/index.js hidden -import { handleRequest } from './server.js'; +```js src/Total.js +const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, +}); -// Mock the fetch API to simulate server responses -const originalFetch = window.fetch; -window.fetch = async (url, options) => { - if (url === '/api/submit') { - const body = JSON.parse(options.body); - const result = await handleRequest(body.name); - return { - ok: result.status >= 200 && result.status < 300, - status: result.status, - json: async () => result.body - }; - } - return originalFetch(url, options); -}; +export default function Total({quantity, isPending}) { + return ( +
+ Total + {isPending ? '🌀' : ''}{formatter.format(quantity * 9999)} +
+ ); +} +``` -import { StrictMode } from 'react'; -import { createRoot } from 'react-dom/client'; -import App from './App.js'; +```js src/api.js hidden +export async function addToCart(count) { + await new Promise(resolve => setTimeout(resolve, 1000)); + return count + 1; +} -const root = createRoot(document.getElementById('root')); -root.render( - - - -); +export async function removeFromCart(count) { + await new Promise(resolve => setTimeout(resolve, 1000)); + return Math.max(0, count - 1); +} ``` ```css -div { +.checkout { display: flex; flex-direction: column; - gap: 8px; - max-width: 300px; + gap: 12px; + padding: 16px; + border: 1px solid #ccc; + border-radius: 8px; + font-family: system-ui; } -.success { - color: green; +.checkout h2 { + margin: 0 0 8px 0; } -.error { - color: red; +.row { + display: flex; + justify-content: space-between; + align-items: center; } -``` - - - -The example above demonstrates a complete error handling pattern: - -1. **Try/catch** wraps the entire request to catch network failures -2. **`response.ok`** checks for HTTP errors (status 200-299 is ok, anything else is not) -3. **Status code checking** distinguishes validation errors (400) from server errors (5xx) -4. **Structured error state** separates field-specific errors (`fieldErrors`) from general errors (`error`) - ---- - -### Queue multiple actions {/*queue-multiple-actions*/} - -When you call `action` multiple times, React queues and executes them sequentially. Each `action` receives the state returned by the previous `action`. The component does not re-render between queued actions—React waits until all queued actions complete before updating the UI. - - - -```js src/App.js -import { useActionState, startTransition } from 'react'; -let nextId = 0; +.stepper { + display: flex; + align-items: center; + gap: 8px; +} -async function addItem(prevItems, name) { - await new Promise(resolve => setTimeout(resolve, 300)); - return [...prevItems, { id: nextId++, name }]; +.qty { + min-width: 20px; + text-align: center; } -export default function ShoppingList() { - const [items, action, isPending] = useActionState(addItem, []); +.buttons { + display: flex; + flex-direction: column; + gap: 2px; +} - function handleAddMultiple() { - startTransition(() => { - action('Apples'); - action('Bananas'); - action('Oranges'); - }); - } +.buttons button { + padding: 0 8px; + font-size: 10px; + line-height: 1.2; + cursor: pointer; +} - return ( -
- -
    - {items.map((item) => ( -
  • {item.name}
  • - ))} -
-
- ); +.pending { + width: 20px; + text-align: center; } -``` -```css -div { - display: flex; - flex-direction: column; - gap: 8px; - align-items: flex-start; +.total { + font-weight: bold; } -ul { - margin: 0; - padding-left: 20px; +hr { + width: 100%; + border: none; + border-top: 1px solid #ccc; + margin: 4px 0; } ```
- -If the `reducerAction` throws an error, React cancels all remaining queued `action` calls and shows the nearest error boundary. To prevent this, catch errors within your `reducerAction` and return an error state instead of throwing. - - +When the stepper arrow is clicked, `setOptimisticCount` immediately updates the quantity, and `action()` queues the `updateCart`. We show a pending indicator on both the quantity and total to give the user feedback that their update is still being applied. --- -### Using action state in action props {/*using-action-state-in-action-props*/} +### Using with `` action props {/*use-with-a-form*/} -When you pass the `action` function to a component that exposes an [Action prop](/reference/react/useTransition#exposing-action-props-from-components), you don't need to wrap the call in `startTransition` yourself. The component handles the transition internally. +The `action` function can be passed as the `action` prop to a ``. -This example shows an `App` that uses `useActionState` for the count and passes `action` to `SubmitButton`'s `action` prop: +When used this way, React automatically wraps the submission in a transition, so you don't need to call `startTransition` yourself. The `reducerAction` receives the previous state and the submitted `FormData`: -```js src/App.js active -import { useActionState } from 'react'; -import SubmitButton from './SubmitButton'; -import { incrementOnServer } from './actions.js'; - -async function increment(count) { - await incrementOnServer(); - return count + 1; -} - -export default function App() { - const [count, action, isPending] = useActionState(increment, 0); +```js src/App.js +import { useActionState, useOptimistic } from 'react'; +import { addToCart, removeFromCart } from './api'; +import Total from './Total'; + +export default function Checkout() { + const [count, action, isPending] = useActionState(updateCart, 0); + const [optimisticCount, setOptimisticCount] = useOptimistic(count); + + async function formAction(formData) { + const type = formData.get('type'); + if (type === 'ADD') { + setOptimisticCount(c => c + 1); + } else { + setOptimisticCount(c => Math.max(0, c - 1)); + } + return action(formData); + } return ( -
- - Increment - -

Count: {count}

-
+ +

Checkout

+
+ Eras Tour Tickets + + {isPending && '🌀'} + {optimisticCount} + + + + + +
+
+ + ); } + +async function updateCart(prevCount, formData) { + const type = formData.get('type'); + switch (type) { + case 'ADD': { + return await addToCart(prevCount); + } + case 'REMOVE': { + return await removeFromCart(prevCount); + } + } + return prevCount; +} ``` -```js src/SubmitButton.js -import { startTransition } from 'react'; +```js src/Total.js +const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, +}); -export default function SubmitButton({ action, isPending, children }) { +export default function Total({quantity, isPending}) { return ( - +
+ Total + {isPending ? '🌀' : ''}{formatter.format(quantity * 9999)} +
); } ``` -```js src/actions.js hidden -export async function incrementOnServer() { +```js src/api.js hidden +export async function addToCart(count) { + await new Promise(resolve => setTimeout(resolve, 1000)); + return count + 1; +} + +export async function removeFromCart(count) { await new Promise(resolve => setTimeout(resolve, 1000)); + return Math.max(0, count - 1); } ``` ```css -div { +.checkout { display: flex; flex-direction: column; + gap: 12px; + padding: 16px; + border: 1px solid #ccc; + border-radius: 8px; + font-family: system-ui; +} + +.checkout h2 { + margin: 0 0 8px 0; +} + +.row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.stepper { + display: flex; + align-items: center; gap: 8px; - align-items: flex-start; +} + +.qty { + min-width: 20px; + text-align: center; +} + +.buttons { + display: flex; + flex-direction: column; + gap: 2px; +} + +.buttons button { + padding: 0 8px; + font-size: 10px; + line-height: 1.2; + cursor: pointer; +} + +.pending { + width: 20px; + text-align: center; +} + +.total { + font-weight: bold; +} + +hr { + width: 100%; + border: none; + border-top: 1px solid #ccc; + margin: 4px 0; } ```
-The `SubmitButton` component wraps the `action` call in `startTransition`, so `isPending` updates correctly. This pattern lets you build reusable button components that handle transitions internally. +In this example, when the user clicks the stepper arrows, the button submits the form and `useActionState` calls `updateCart` with the form data. The action uses `useOptimistic` to immediately show the new quantity while the server confirms the update. + + + +When used with a [Server Function](/reference/rsc/server-functions), `useActionState` allows the server's response to be shown before hydration (when React attaches to server-rendered HTML) completes. You can also use the optional `permalink` parameter for progressive enhancement (allowing the form to work before JavaScript loads) on pages with dynamic content. This is typically handled by your framework for you. + + --- -### Use with a form {/*use-with-a-form*/} +### Cancelling queued Actions {/*cancelling-queued-actions*/} -The `action` function can be passed as the `action` prop to a form. When used this way, React automatically wraps the submission in a transition, so you don't need to call `startTransition` yourself. The `reducerAction` receives the previous state and the submitted `FormData`. +You can use an AbortController pattern to cancel pending Actions: ```js src/App.js -import { useState, startTransition } from 'react'; -import EditName from './EditName'; +import { useActionState, useOptimistic, useRef } from 'react'; +import { addToCart, removeFromCart } from './api'; +import QuantityStepper from './QuantityStepper'; +import Total from './Total'; + +export default function Checkout() { + const abortRef = useRef(null); + const [count, action, isPending] = useActionState(updateCart, 0); + + const [optimisticCount, setOptimisticCount] = useOptimistic(count); -export default function App() { - const [name, setName] = useState('Taylor'); + async function addAction() { + if (abortRef.current) { + abortRef.current.abort(); + } + abortRef.current = new AbortController(); + setOptimisticCount(c => c + 1); + await action({type: 'ADD', signal: abortRef.current.signal}); + } - function handleSubmit(newName) { - startTransition(() => { - setName(newName); - }); + async function removeAction() { + if (abortRef.current) { + abortRef.current.abort(); + } + abortRef.current = new AbortController(); + setOptimisticCount(c => Math.max(0, c - 1)); + await action({type: 'REMOVE', signal: abortRef.current.signal}); } - return ; + return ( +
+

Checkout

+
+ Eras Tour Tickets + +
+
+ +
+ ); +} + + +async function updateCart(prevCount, update) { + switch (update.type) { + case 'ADD': { + try { + return await addToCart(prevCount, {signal: update.signal}); + } catch (e) { + return prevCount + 1; + } + } + case 'REMOVE': { + try { + return await removeFromCart(prevCount, {signal: update.signal}); + } catch (e) { + return prevCount - 1; + } + } + } + return prevCount; } ``` -```js src/EditName.js active -import { useActionState } from 'react'; -import { updateName } from './actions.js'; +```js src/QuantityStepper.js +import { useTransition } from 'react'; -export default function EditName({ name, onSubmit }) { - const [state, formAction, isPending] = useActionState(submitAction, null); +export default function QuantityStepper({value, increaseAction, decreaseAction}) { + const [isPending, startTransition] = useTransition(); - async function submitAction(prevState, formData) { - const newName = formData.get('name'); - const result = await updateName(newName); - if (result.error) { - return result; - } - onSubmit(result.name); - return null; + function handleIncrease() { + startTransition(async () => { + await increaseAction(); + }); + } + + function handleDecrease() { + startTransition(async () => { + await decreaseAction(); + }); } return ( -
-

Your name is: {name}

- - - {state?.error &&

{state.error}

} -
+ + {isPending && '🌀'} + {value} + + + + + ); } ``` -```js src/actions.js hidden -export async function updateName(name) { - await new Promise(resolve => setTimeout(resolve, 1000)); - if (!name || name.trim() === '') { - return { error: 'Name cannot be empty' }; +```js src/Total.js +const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, +}); + +export default function Total({quantity, isPending}) { + return ( +
+ Total + {isPending ? '🌀' : ''}{formatter.format(quantity * 9999)} +
+ ); +} +``` + +```js src/api.js hidden +class AbortError extends Error { + name = "AbortError"; + constructor(message = "The operation was aborted") { + super(message); } - return { name }; +} + +function sleep(ms, signal){ + if (!signal) return new Promise((resolve) => setTimeout(resolve, ms)); + if (signal.aborted) return Promise.reject(new AbortError()); + + return new Promise((resolve, reject) => { + const id = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + const onAbort = () => { + clearTimeout(id); + reject(new AbortError()); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} +export async function addToCart(count, opts) { + await sleep(1000, opts?.signal); + return count + 1; +} + +export async function removeFromCart(count, opts) { + await sleep(1000, opts?.signal); + return Math.max(0, count - 1); } ``` ```css -form { +.checkout { display: flex; flex-direction: column; + gap: 12px; + padding: 16px; + border: 1px solid #ccc; + border-radius: 8px; + font-family: system-ui; +} + +.checkout h2 { + margin: 0 0 8px 0; +} + +.row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.stepper { + display: flex; + align-items: center; gap: 8px; - max-width: 300px; } -label { +.qty { + min-width: 20px; + text-align: center; +} + +.buttons { display: flex; flex-direction: column; - gap: 4px; + gap: 2px; } -.error { - color: red; +.buttons button { + padding: 0 8px; + font-size: 10px; + line-height: 1.2; + cursor: pointer; +} + +.pending { + width: 20px; + text-align: center; +} + +.total { + font-weight: bold; +} + +hr { + width: 100%; + border: none; + border-top: 1px solid #ccc; + margin: 4px 0; } ```
-In this example, when the user submits the form, `useActionState` calls the `reducerAction` with the form data. The `reducerAction` validates the name, calls the server, and either returns an error state or calls `onSubmit` to update the parent. +Try clicking increase or decrease multiple times, and notice that the total updates within 1 second no matter how many times you click. This works because we're using an AbortController to "complete" the previous Action so the next Action can proceed. + + -When used with a [Server Function](/reference/rsc/server-functions), `useActionState` allows the server's response to be shown before hydration (when React attaches to server-rendered HTML) completes. You can also use the optional `permalink` parameter for progressive enhancement (allowing the form to work before JavaScript loads) on pages with dynamic content. +Aborting an Action isn't always safe, which is why `useActionState` doesn't do it by default. + + --- @@ -894,7 +1272,7 @@ Only call `action` in response to user events (like form submissions or button c ### My actions are being skipped {/*actions-skipped*/} -If you call `action` multiple times and some of them don't run, it may be because an earlier `action` call threw an error. When an `reducerAction` throws, React skips all subsequently queued `action` calls. +If you call `action` multiple times and some of them don't run, it may be because an earlier `action` call threw an error. When a `reducerAction` throws, React skips all subsequently queued `action` calls. To handle this, catch errors within your `reducerAction` and return an error state instead of throwing: