--- title: useActionState --- `useActionState`는 폼 액션의 결과를 기반으로 State를 업데이트할 수 있도록 제공하는 Hook입니다. ```js const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState, permalink?); ``` 이전 React Canary 버전에서는 이 API가 React DOM에 포함되어 있었고, `useFormState`라고 불렸습니다. ```js import { useActionState } from 'react'; function reducerAction(previousState, actionPayload) { // ... } function StatefulForm({}) { const [state, formAction] = useActionState(increment, 0); return (
{state}
); } ``` 폼 State는 폼을 마지막으로 제출했을 때 액션에서 반환되는 값입니다. 아직 폼을 제출하지 않았다면, `initialState`로 설정됩니다. 서버 함수Server Function와 함께 사용하는 경우, `useActionState`를 통해 하이드레이션Hydration이 끝나기 전에도 폼 제출에 대한 서버 응답을 표시할 수 있습니다. [아래 예시를 참고하세요.](#usage) #### 매개변수 {/*parameters*/} * `fn`: 폼이 제출되거나 버튼이 눌렸을 때 호출되는 함수입니다. 함수가 호출되면 첫 번째 인수로 폼의 이전 State(처음에는 전달한 `initialState`, 이후에는 이전 반환값)가 전달되고, 그 뒤로는 폼 액션이 일반적으로 받는 인수들이 전달됩니다. * `initialState`: State가 처음에 가지기를 원하는 값입니다. 이는 직렬화 가능한 값이면 무엇이든 될 수 있습니다. 이 인수는 액션이 처음 호출된 후에는 무시됩니다. * **optional** `permalink`: 이 폼이 수정하는 고유한 페이지 URL을 포함하는 문자열입니다. 동적 콘텐츠가 있는 페이지(예: 피드)에서 점진적 향상Progressive Enhancement과 함께 사용됩니다. 만약 `fn`이 [서버 함수](/reference/rsc/server-functions)이고, 폼이 자바스크립트 번들이 로드되기 전에 제출되면, 브라우저는 현재 페이지의 URL 대신 지정된 영구 링크Permalink URL로 이동합니다. React가 State를 전달하는 방법을 알 수 있도록, 동일한 폼 컴포넌트가 대상 페이지에 렌더링되도록 해야 합니다. (동일한 액션 `fn`과 `permalink` 포함.) 폼이 하이드레이션된 후, 이 매개변수는 더 이상 효과가 없습니다. {/* TODO T164397693: link to serializable values docs once it exists */} #### 반환값 {/*returns*/} `useActionState`는 다음 세 가지 값을 담은 배열을 반환합니다. 1. 현재 State입니다. 첫 렌더링 시에는 `initialState`와 일치하며, 액션이 실행된 후에는 해당 액션이 반환한 값과 일치합니다. 2. `form` 컴포넌트의 `action` Prop이나, 폼 내부 `button` 컴포넌트의 `formAction` Prop에 전달할 수 있는 새 액션입니다. 이 액션은 [`startTransition`](/reference/react/startTransition) 내에서 수동으로 호출할 수도 있습니다. 3. 현재 Transition이 대기 중인지 알려주는 `isPending` 플래그입니다. #### 주의 사항 {/*caveats*/} * React 서버 컴포넌트를 지원하는 프레임워크에서 `useActionState`를 사용하면, 클라이언트 자바스크립트 실행 전에도 폼과 상호작용할 수 있습니다. 만약 서버 컴포넌트를 사용하지 않는다면, 이는 단순히 컴포넌트 지역 State와 동일하게 동작합니다. * `useActionState`에 전달된 함수는 첫 번째 인수로 이전 또는 초기 State를 추가로 받습니다. 즉, 직접 폼 액션을 사용했을 때와 비교해 함수의 시그니처가 달라질 수 있습니다. --- ## 사용법 {/*usage*/} ### 폼 액션에서 반환된 정보 사용하기 {/*using-information-returned-by-a-form-action*/} 컴포넌트의 최상위 레벨에서 `useActionState`를 호출하면, 폼이 마지막으로 제출되었을 때 액션이 반환한 값에 접근할 수 있습니다. ```js [[1, 7, "count"], [2, 7, "dispatchAction"], [3, 7, "isPending"]] import { useActionState } from 'react'; async function addToCartAction(prevCount) { // ... } function Counter() { const [count, dispatchAction, isPending] = useActionState(addToCartAction, 0); // ... } ``` `useActionState`가 반환하는 배열은 다음과 같은 요소를 갖습니다. 1. 폼의 현재 State는, 처음에는 전달한 초기 State로 설정되며, 폼이 제출된 후에는 전달한 액션의 반환값으로 설정됩니다. 2. `
`의 `action` Prop에 전달하거나 `startTransition` 안에서 직접 호출할 수 있는새로운 액션입니다. 3. 액션이 처리되는 동안 사용할 수 있는 대기Pending State입니다. 폼이 제출되면, 제공한 액션 함수가 호출되며, 해당 함수의 반환값이 새로운 현재 State로 설정됩니다. 이 액션 함수는 첫 번째 인수로 현재 State를 추가로 전달받습니다. 처음 제출될 때는초기 State가 전달되며, 이후 제출부터는 직전 호출 시 반환된 값이 전달됩니다. 나머지 인수들은 useActionState를 사용하지 않았을 때와 동일합니다. ```js [[3, 1, "action"], [1, 1, "currentState"]] function action(currentState, formData) { // ... return 'next state'; } ``` #### 오류 표시하기 {/*display-form-errors*/} 서버 함수Server Function에서 반환된 오류 메시지나 토스트 메시지를 표시하려면, 해당 액션을 `useActionState`로 감싸주세요. ```js src/App.js import { useActionState } from "react"; import { addToCart } from "./actions.js"; export default function Checkout() { const [count, dispatchAction, isPending] = useActionState(async (prevCount) => { return await addToCart(prevCount) }, 0); export default function App() { return ( <> ); } ``` ```js src/actions.js "use server"; 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); }); } return (

Checkout

Eras Tour Tickets Qty: {count}

); } ``` ```css src/styles.css hidden form { border: solid 1px black; margin-bottom: 24px; padding: 12px; } export default function Total({quantity}) { return (
Total {formatter.format(quantity * 9999)}
); } ``` ```js src/api.js 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 .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; } .row button { margin-left: auto; min-width: 150px; } .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 `addToCartAction`. React shows the pending state until all the tickets are added, and then re-renders with the final state. #### How `useActionState` queuing works {/*how-useactionstate-queuing-works*/} Try clicking "Add Ticket" multiple times. Every time you click, a new `addToCartAction` is queued. Since there's an artificial 1 second delay, that means 4 clicks will take ~4 seconds to complete. **This is intentional in the design of `useActionState`.** We have to wait for the previous result of `addToCartAction` in order to pass the `prevCount` to the next call to `addToCartAction`. That means React has to wait for the previous Action to finish before calling the next Action. 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`. #### 폼 제출 후 구조화된 정보 표시하기 {/*display-structured-information-after-submitting-a-form*/} 서버 함수Server Function의 반환값은 직렬화 가능한 어떤 값이든 가능합니다. 예를 들어, 액션 성공 여부를 나타내는 불리언, 오류 메시지, 업데이트된 객체 등 다양하게 활용할 수 있습니다. ```js src/App.js import { useActionState, startTransition } from 'react'; import { addToCart, removeFromCart } from './api'; import Total from './Total'; export default function Checkout() { const [count, dispatchAction, isPending] = useActionState(updateCartAction, 0); function handleAdd() { startTransition(() => { dispatchAction({ type: 'ADD' }); }); } function handleRemove() { startTransition(() => { dispatchAction({ type: 'REMOVE' }); }); } return (

Checkout

Eras Tour Tickets {isPending ? '🌀' : count}

); } async function updateCartAction(prevCount, actionPayload) { switch (actionPayload.type) { case 'ADD': { return await addToCart(prevCount); } case 'REMOVE': { return await removeFromCart(prevCount); } } return prevCount; } ``` ```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 .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; } .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; } ```
When you click to increase or decrease the quantity, an `"ADD"` or `"REMOVE"` is dispatched. In the `reducerAction`, different APIs are called to update the quantity. In this example, we use the pending state of the Actions to replace both the quantity and the total. If you want to provide immediate feedback, such as immediately updating the quantity, you can use `useOptimistic`. #### 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 Actions in parallel, use `useState` and `useTransition` directly. --- ### Using with `useOptimistic` {/*using-with-useoptimistic*/} You can combine `useActionState` with [`useOptimistic`](/reference/react/useOptimistic) to show immediate UI feedback: ```js src/App.js import { useActionState, startTransition, useOptimistic } from 'react'; import { addToCart, removeFromCart } from './api'; import Total from './Total'; export default function Checkout() { const [count, dispatchAction, isPending] = useActionState(updateCartAction, 0); const [optimisticCount, setOptimisticCount] = useOptimistic(count); function handleAdd() { startTransition(() => { setOptimisticCount(c => c + 1); dispatchAction({ type: 'ADD' }); }); } function handleRemove() { startTransition(() => { setOptimisticCount(c => c - 1); dispatchAction({ type: 'REMOVE' }); }); } return (

Checkout

Eras Tour Tickets {isPending && '🌀'} {optimisticCount}

); } async function updateCartAction(prevCount, actionPayload) { switch (actionPayload.type) { case 'ADD': { return await addToCart(prevCount); } case 'REMOVE': { return await removeFromCart(prevCount); } } return prevCount; } ``` ```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 .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; } .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; } ```
`setOptimisticCount` immediately updates the quantity, and `dispatchAction()` queues the `updateCartAction`. A pending indicator appears on both the quantity and total to give the user feedback that their update is still being applied. --- ### 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 `useOptimistic` yourself. This example shows using the `increaseAction` and `decreaseAction` props of a QuantityStepper component: ```js src/App.js import { useActionState } from 'react'; import { addToCart, removeFromCart } from './api'; import QuantityStepper from './QuantityStepper'; import Total from './Total'; export default function Checkout() { const [count, dispatchAction, isPending] = useActionState(updateCartAction, 0); function addAction() { dispatchAction({type: 'ADD'}); } function removeAction() { dispatchAction({type: 'REMOVE'}); } return (

Checkout

Eras Tour Tickets

); } async function updateCartAction(prevCount, actionPayload) { switch (actionPayload.type) { case 'ADD': { return await addToCart(prevCount); } case 'REMOVE': { return await removeFromCart(prevCount); } } return prevCount; } ``` ```js src/QuantityStepper.js import { startTransition, useOptimistic } from 'react'; export default function QuantityStepper({value, increaseAction, decreaseAction}) { const [optimisticValue, setOptimisticValue] = useOptimistic(value); const isPending = value !== optimisticValue; function handleIncrease() { startTransition(async () => { setOptimisticValue(c => c + 1); await increaseAction(); }); } function handleDecrease() { startTransition(async () => { setOptimisticValue(c => Math.max(0, c - 1)); await decreaseAction(); }); } return ( {isPending && '🌀'} {optimisticValue} ); } ``` ```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 .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; } .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 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. --- ### Cancelling queued Actions {/*cancelling-queued-actions*/} You can use an `AbortController` to cancel pending Actions: ```js src/App.js import { useActionState, 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, dispatchAction, isPending] = useActionState(updateCartAction, 0); async function addAction() { if (abortRef.current) { abortRef.current.abort(); } abortRef.current = new AbortController(); await dispatchAction({ type: 'ADD', signal: abortRef.current.signal }); } async function removeAction() { if (abortRef.current) { abortRef.current.abort(); } abortRef.current = new AbortController(); await dispatchAction({ type: 'REMOVE', signal: abortRef.current.signal }); } return (

Checkout

Eras Tour Tickets

); } async function updateCartAction(prevCount, actionPayload) { switch (actionPayload.type) { case 'ADD': { try { return await addToCart(prevCount, { signal: actionPayload.signal }); } catch (e) { return prevCount + 1; } } case 'REMOVE': { try { return await removeFromCart(prevCount, { signal: actionPayload.signal }); } catch (e) { return Math.max(0, prevCount - 1); } } } return prevCount; } ``` ```js src/QuantityStepper.js import { startTransition, useOptimistic } from 'react'; export default function QuantityStepper({value, increaseAction, decreaseAction}) { const [optimisticValue, setOptimisticValue] = useOptimistic(value); const isPending = value !== optimisticValue; function handleIncrease() { startTransition(async () => { setOptimisticValue(c => c + 1); await increaseAction(); }); } function handleDecrease() { startTransition(async () => { setOptimisticValue(c => Math.max(0, c - 1)); await decreaseAction(); }); } return ( {isPending && '🌀'} {optimisticValue} ); } ``` ```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 class AbortError extends Error { name = 'AbortError'; constructor(message = 'The operation was aborted') { super(message); } } 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 .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; } .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; } ```
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 it uses an `AbortController` to "complete" the previous Action so the next Action can proceed. Aborting an Action isn't always safe. For example, if the Action performs a mutation (like writing to a database), aborting the network request doesn't undo the server-side change. This is why `useActionState` doesn't abort by default. It's only safe when you know the side effect can be safely ignored or retried. --- ### Using with `` Action props {/*use-with-a-form*/} You can pass the `dispatchAction` function as the `action` prop to a ``. 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 { useActionState, useOptimistic } from 'react'; import { addToCart, removeFromCart } from './api'; import Total from './Total'; export default function Checkout() { const [count, dispatchAction, isPending] = useActionState(updateCartAction, 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 dispatchAction(formData); } return (

Checkout

Eras Tour Tickets {isPending && '🌀'} {optimisticCount}

); } export default function App() { return ( <> ); } ``` ```js src/actions.js "use server"; export async function addToCart(prevState, queryData) { const itemID = queryData.get('itemID'); if (itemID === "1") { return { success: true, cartSize: 12, }; } else { return { success: false, message: "The item is sold out.", }; } return prevCount; } ``` ```css src/styles.css hidden form { border: solid 1px black; margin-bottom: 24px; padding: 12px; } 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 .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; } .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; } ```
In this example, when the user clicks the stepper arrows, the button submits the form and `useActionState` calls `updateCartAction` with the form data. The example 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. See the [`
`](/reference/react-dom/components/form#handle-form-submission-with-a-server-function) docs for more information on using Actions with forms. --- ### Handling errors {/*handling-errors*/} There are two ways to handle errors with `useActionState`. For known errors, such as "quantity not available" validation errors from your backend, you can return it as part of your `reducerAction` state and display it in the UI. For unknown errors, such as `undefined is not a function`, you can throw an error. React will cancel all queued Actions and shows the nearest [Error Boundary](/reference/react/Component#catching-rendering-errors-with-an-error-boundary) by rethrowing the error from the `useActionState` hook. ```js src/App.js import {useActionState, startTransition} from 'react'; import {ErrorBoundary} from 'react-error-boundary'; import {addToCart} from './api'; import Total from './Total'; function Checkout() { const [state, dispatchAction, isPending] = useActionState( async (prevState, quantity) => { const result = await addToCart(prevState.count, quantity); if (result.error) { // Return the error from the API as state return {...prevState, error: `Could not add quanitiy ${quantity}: ${result.error}`}; } if (!isPending) { // Clear the error state for the first dispatch. return {count: result.count, error: null}; } // Return the new count, and any errors that happened. return {count: result.count, error: prevState.error}; }, { count: 0, error: null, } ); function handleAdd(quantity) { startTransition(() => { dispatchAction(quantity); }); } return (

Checkout

Eras Tour Tickets {isPending && '🌀 '}Qty: {state.count}
{state.error &&
{state.error}
}
); } export default function App() { return ( (

Something went wrong

The action could not be completed.

)}>
); } ``` ```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, quantity) { await new Promise((resolve) => setTimeout(resolve, 1000)); if (quantity > 5) { return {error: 'Quantity not available'}; } else if (isNaN(quantity)) { throw new Error('Quantity must be a number'); } return {count: count + quantity}; } ``` ```css .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; } .total { font-weight: bold; } hr { width: 100%; border: none; border-top: 1px solid #ccc; margin: 4px 0; } button { padding: 8px 16px; cursor: pointer; } .buttons { display: flex; gap: 8px; } .error { color: red; font-size: 14px; } ``` ```json package.json hidden { "dependencies": { "react": "19.0.0", "react-dom": "19.0.0", "react-scripts": "^5.0.0", "react-error-boundary": "4.0.3" }, "main": "/index.js" } ```
In this example, "Add 10" simulates an API that returns a validation error, which `updateCartAction` stores in state and displays inline. "Add NaN" results in an invalid count, so `updateCartAction` throws, which propagates through `useActionState` to the `ErrorBoundary` and shows a reset UI. --- ## 문제 해결 {/*troubleshooting*/} ### 액션이 더 이상 제출된 폼 데이터를 읽을 수 없습니다 {/*my-action-can-no-longer-read-the-submitted-form-data*/} 액션을 `useActionState`로 감싸면 *첫 번째 인수*로 "이전(또는 현재) State"가 추가됩니다. 따라서 일반적인 폼 액션과 달리, 제출된 폼 데이터는 *두 번째 인수*에서 확인해야 합니다. ```js import { useActionState, startTransition } from 'react'; function MyComponent() { const [state, dispatchAction, isPending] = useActionState(myAction, null); function handleClick() { // ✅ Correct: wrap in startTransition startTransition(() => { dispatchAction(); }); } // ... } ``` When `dispatchAction` is passed to an Action prop, React automatically wraps it in a Transition. --- ### My Action cannot read form data {/*action-cannot-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 {2,7} // Without useActionState function action(formData) { const name = formData.get('name'); } // With useActionState function action(prevState, formData) { const name = formData.get('name'); } ``` --- ### My actions are being skipped {/*actions-skipped*/} If you call `dispatchAction` multiple times and some of them don't run, it may be because an earlier `dispatchAction` call threw an error. When a `reducerAction` throws, React skips all subsequently queued `dispatchAction` 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 }; } } ``` --- ### My state doesn't reset {/*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, dispatchAction, isPending] = useActionState(formAction, initialState); function handleReset() { startTransition(() => { dispatchAction(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, or a `` `action` prop, which resets automatically after submission. --- ### I'm getting an error: "An async function with useActionState was called outside of a transition." {/*async-function-outside-transition*/} A common mistake is to forget to call `dispatchAction` from inside a Transition: 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. This error happens because `dispatchAction` must run inside a Transition: ```js function MyComponent() { const [state, dispatchAction, isPending] = useActionState(myAsyncAction, null); function handleClick() { // ❌ Wrong: calling dispatchAction outside a Transition dispatchAction(); } // ... } ``` To fix, either wrap the call in [`startTransition`](/reference/react/startTransition): ```js import { useActionState, startTransition } from 'react'; function MyComponent() { const [state, dispatchAction, isPending] = useActionState(myAsyncAction, null); function handleClick() { // ✅ Correct: wrap in startTransition startTransition(() => { dispatchAction(); }); } // ... } ``` Or pass `dispatchAction` to an Action prop, is call in a Transition: ```js function MyComponent() { const [state, dispatchAction, isPending] = useActionState(myAsyncAction, null); // ✅ Correct: action prop wraps in a Transition for you return ; } ``` --- ### I'm getting an error: "Cannot update action state while rendering" {/*cannot-update-during-render*/} You cannot call `dispatchAction` during render: Cannot update action state while rendering. This causes an infinite loop because calling `dispatchAction` schedules a state update, which triggers a re-render, which calls `dispatchAction` again. ```js function MyComponent() { const [state, dispatchAction, isPending] = useActionState(myAction, null); // ❌ Wrong: calling dispatchAction during render dispatchAction(); // ... } ``` To fix, only call `dispatchAction` in response to user events (like form submissions or button clicks).