useActionState는 Actions를 사용해서 부수 효과(side effects)를 포함한 상태 업데이트를 할 수 있게 해주는 React Hook이에요.
const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState, permalink?);
useActionState(reducerAction, initialState, permalink?) {/useactionstate/}컴포넌트의 최상위 레벨에서 useActionState를 호출해서 Action의 결과에 대한 상태를 만들 수 있어요.
import { useActionState } from 'react';
// reducerAction 함수
function reducerAction(previousState, actionPayload) {
// ...
}
function MyCart({initialState}) {
const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState);
// ...
}
reducerAction: Action이 트리거될 때 호출되는 함수예요. 이 함수가 호출되면, 첫 번째 인자로 이전 상태(처음에는 여러분이 제공한 initialState, 그 이후에는 이전 호출의 반환값)를 받고, 그 다음 인자로 dispatchAction에 전달된 actionPayload를 받아요.initialState: 초기에 상태가 가지길 원하는 값이에요. dispatchAction이 처음으로 호출된 후에는 React가 이 인자를 무시해요.permalink: 이 폼이 수정하는 고유한 페이지 URL을 담은 문자열이에요.reducerAction이 서버 함수이고 JavaScript 번들이 로드되기 전에 폼이 제출되면, 브라우저는 현재 페이지의 URL이 아니라 지정된 permalink URL로 이동해요.💡 부연 설명: permalink는 JavaScript가 로드되기 전에도 폼이 동작하도록 만들어주는 기능이에요. 서버 사이드 렌더링과 함께 사용하면, 사용자가 JavaScript가 아직 로드되지 않은 상태에서 폼을 제출해도 올바른 페이지로 이동할 수 있어요.
useActionState는 정확히 세 개의 값을 가진 배열을 반환해요:
initialState와 일치해요. dispatchAction이 호출된 후에는 reducerAction이 반환한 값과 일치해요.dispatchAction 함수.isPending 플래그.💡 부연 설명: 배열 구조 분해를 사용해서
const [상태, 액션디스패처, 로딩중] = useActionState(...)처럼 원하는 이름으로 받을 수 있어요.
useActionState는 Hook이므로, 컴포넌트의 최상위 레벨이나 여러분만의 Hook에서만 호출할 수 있어요. 반복문이나 조건문 안에서는 호출할 수 없어요. 만약 그렇게 해야 한다면, 새로운 컴포넌트를 추출하고 상태를 그 안으로 옮기세요.dispatchAction 호출을 순차적으로 큐에 넣고 실행해요. 각 reducerAction 호출은 이전 호출의 결과를 받아요.dispatchAction 함수는 안정적인 식별자를 가지고 있어서, Effect 의존성 배열에서 생략되는 경우가 많아요. 하지만 포함시켜도 Effect가 다시 실행되지는 않아요. 린터가 에러 없이 의존성을 생략하도록 허용한다면, 그렇게 해도 안전해요. Effect 의존성 제거에 대해 더 알아보기.permalink 옵션을 사용할 때는, 목적지 페이지에서도 동일한 폼 컴포넌트가 렌더링되도록 해야 해요 (동일한 reducerAction과 permalink 포함). 그래야 React가 상태를 전달하는 방법을 알 수 있어요. 페이지가 인터랙티브해지면, 이 매개변수는 아무런 효과가 없어요.initialState는 직렬화 가능해야 해요 (일반 객체, 배열, 문자열, 숫자 같은 값들).dispatchAction이 에러를 던지면, React는 큐에 있는 모든 액션을 취소하고 가장 가까운 에러 경계를 보여줘요.dispatchAction은 반드시 Action 안에서 호출되어야 해요.
startTransition으로 감싸거나, Action prop으로 전달할 수 있어요. 그 범위 밖에서의 호출은 Transition의 일부로 취급되지 않고 개발 모드에서 에러를 기록해요.
reducerAction 함수 {/reduceraction/}useActionState에 전달되는 reducerAction 함수는 이전 상태를 받아서 새로운 상태를 반환해요.
useReducer의 reducer와 달리, reducerAction은 비동기일 수 있고 부수 효과를 수행할 수 있어요:
// reducerAction 함수 예시
async function reducerAction(previousState, actionPayload) {
const newState = await post(actionPayload);
return newState;
}
dispatchAction을 호출할 때마다, React는 actionPayload와 함께 reducerAction을 호출해요. reducer는 데이터를 전송하는 것과 같은 부수 효과를 수행하고, 새로운 상태를 반환해요. dispatchAction이 여러 번 호출되면, React는 순서대로 큐에 넣고 실행해서 이전 호출의 결과가 현재 호출의 previousState로 전달돼요.
💡 부연 설명: 여러 번 클릭하면 큐에 쌓여서 순차적으로 실행되는 거예요. 예를 들어 1초가 걸리는 작업을 4번 클릭하면, 4초 후에 모두 완료되는 식이죠.
previousState: 마지막 상태. 처음에는 initialState와 동일해요. 첫 번째 dispatchAction 호출 이후에는, 마지막으로 반환된 상태와 동일해요.
선택적 actionPayload: dispatchAction에 전달된 인자. 어떤 타입의 값이든 될 수 있어요. useReducer 관례와 비슷하게, 보통 action을 식별하는 type 속성과, 선택적으로 추가 정보를 담은 다른 속성들을 가진 객체예요.
reducerAction은 새로운 상태를 반환하고, 그 상태로 다시 렌더링하기 위해 Transition을 트리거해요.
reducerAction은 동기 또는 비동기일 수 있어요. 알림 표시 같은 동기 작업이나, 서버에 업데이트를 전송하는 것 같은 비동기 작업을 수행할 수 있어요.reducerAction은 <StrictMode>에서 두 번 호출되지 않아요. 왜냐하면 reducerAction은 부수 효과를 허용하도록 설계되었기 때문이에요.reducerAction의 반환 타입은 initialState의 타입과 일치해야 해요. TypeScript가 불일치를 추론하면, 상태 타입을 명시적으로 표기해야 할 수도 있어요.reducerAction에서 await 이후에 상태를 설정하면, 현재는 추가적인 startTransition으로 상태 업데이트를 감싸야 해요. 자세한 내용은 startTransition 문서를 참조하세요.actionPayload는 직렬화 가능해야 해요 (일반 객체, 배열, 문자열, 숫자 같은 값들).reducerAction이라고 부를까요? {/why-is-it-called-reduceraction/}useActionState에 전달되는 함수를 reducer action이라고 부르는 이유는:
useReducer처럼 이전 상태를 새로운 상태로 축약(reduce)하기 때문이에요.개념적으로, useActionState는 useReducer와 비슷하지만, reducer에서 부수 효과를 수행할 수 있다는 점이 달라요.
컴포넌트의 최상위 레벨에서 useActionState를 호출해서 Action의 결과에 대한 상태를 만드세요.
import { useActionState } from 'react';
async function addToCartAction(prevCount) {
// ...
}
function Counter() {
const [count, dispatchAction, isPending] = useActionState(addToCartAction, 0);
// ...
}
useActionState는 정확히 세 개의 항목을 가진 배열을 반환해요:
reducerAction을 트리거할 수 있게 해주는 action dispatcher.addToCartAction을 호출하려면, action dispatcher를 호출하세요. React는 이전 count와 함께 addToCartAction 호출을 큐에 넣을 거예요.
// App.js
import { useActionState, startTransition } from 'react';
import { addToCart } from './api';
import Total from './Total';
export default function Checkout() {
const [count, dispatchAction, isPending] = useActionState(async (prevCount) => {
return await addToCart(prevCount)
}, 0);
function handleClick() {
startTransition(() => {
dispatchAction();
});
}
return (
<div className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<span>Qty: {count}</span>
</div>
<div className="row">
<button onClick={handleClick}>Add Ticket{isPending ? ' 🌀' : ' '}</button>
</div>
<hr />
<Total quantity={count} />
</div>
);
}
// Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity}) {
return (
<div className="row total">
<span>Total</span>
<span>{formatter.format(quantity * 9999)}</span>
</div>
);
}
// 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);
}
.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;
}
"Add Ticket"을 클릭할 때마다, React는 addToCartAction 호출을 큐에 넣어요. React는 모든 티켓이 추가될 때까지 대기 상태를 보여주고, 그다음 최종 상태로 다시 렌더링해요.
useActionState 큐잉이 어떻게 작동하나요? {/how-useactionstate-queuing-works/}"Add Ticket"을 여러 번 클릭해보세요. 클릭할 때마다 새로운 addToCartAction이 큐에 추가돼요. 인위적으로 1초 지연이 있으니까, 4번 클릭하면 완료되는 데 약 4초가 걸릴 거예요.
이것은 useActionState 설계에서 의도된 동작이에요.
다음 addToCartAction 호출에 prevCount를 전달하려면 이전 addToCartAction의 결과를 기다려야 해요. 즉, React는 다음 Action을 호출하기 전에 이전 Action이 끝날 때까지 기다려야 한다는 뜻이에요.
일반적으로 useOptimistic과 함께 사용해서 이 문제를 해결할 수 있지만, 더 복잡한 경우에는 큐에 있는 액션 취소하기나 useActionState를 사용하지 않는 것을 고려해볼 수 있어요.
여러 타입을 처리하려면, dispatchAction에 인자를 전달할 수 있어요.
관례적으로, switch 문으로 작성하는 게 일반적이에요. switch의 각 case에서, 다음 상태를 계산하고 반환하세요. 인자는 어떤 형태든 될 수 있지만, action을 식별하는 type 속성을 가진 객체를 전달하는 게 일반적이에요.
// 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 (
<div className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<span className="stepper">
<span className="qty">{isPending ? '🌀' : count}</span>
<span className="buttons">
<button onClick={handleAdd}>▲</button>
<button onClick={handleRemove}>▼</button>
</span>
</span>
</div>
<hr />
<Total quantity={count} isPending={isPending}/>
</div>
);
}
async function updateCartAction(prevCount, actionPayload) {
switch (actionPayload.type) {
case 'ADD': {
return await addToCart(prevCount);
}
case 'REMOVE': {
return await removeFromCart(prevCount);
}
}
return prevCount;
}
// Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity, isPending}) {
return (
<div className="row total">
<span>Total</span>
{isPending ? '🌀 Updating...' : formatter.format(quantity * 9999)}
</div>
);
}
// 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);
}
.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;
}
수량을 증가시키거나 감소시키기 위해 클릭하면, "ADD" 또는 "REMOVE"가 디스패치돼요. reducerAction에서는 서로 다른 API가 호출되어 수량을 업데이트해요.
이 예제에서는 Actions의 대기 상태를 사용해서 수량과 합계를 모두 대체해요. 즉시 피드백을 제공하고 싶다면(예: 수량을 즉시 업데이트), useOptimistic을 사용할 수 있어요.
useActionState는 useReducer와 어떻게 다른가요? {/useactionstate-vs-usereducer/}이 예제가 useReducer와 많이 비슷해 보일 수 있는데, 둘은 서로 다른 목적을 가지고 있어요:
useReducer를 사용하세요 - UI의 상태를 관리할 때. reducer는 반드시 순수해야 해요.
useActionState를 사용하세요 - Action의 상태를 관리할 때. reducer는 부수 효과를 수행할 수 있어요.
useActionState를 사용자 Action에서 발생하는 부수 효과를 위한 useReducer라고 생각할 수 있어요. 이전 Action을 기반으로 다음에 취할 Action을 계산하기 때문에, 호출을 순차적으로 정렬해야 해요. Action을 병렬로 수행하고 싶다면, useState와 useTransition을 직접 사용하세요.
useOptimistic과 함께 사용하기 {/using-with-useoptimistic/}useActionState를 useOptimistic과 결합해서 즉각적인 UI 피드백을 보여줄 수 있어요:
// 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 (
<div className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<span className="stepper">
<span className="pending">{isPending && '🌀'}</span>
<span className="qty">{optimisticCount}</span>
<span className="buttons">
<button onClick={handleAdd}>▲</button>
<button onClick={handleRemove}>▼</button>
</span>
</span>
</div>
<hr />
<Total quantity={optimisticCount} isPending={isPending}/>
</div>
);
}
async function updateCartAction(prevCount, actionPayload) {
switch (actionPayload.type) {
case 'ADD': {
return await addToCart(prevCount);
}
case 'REMOVE': {
return await removeFromCart(prevCount);
}
}
return prevCount;
}
// Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity, isPending}) {
return (
<div className="row total">
<span>Total</span>
<span>{isPending ? '🌀 Updating...' : formatter.format(quantity * 9999)}</span>
</div>
);
}
// 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);
}
.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는 수량을 즉시 업데이트하고, dispatchAction()은 updateCartAction을 큐에 넣어요. 사용자에게 업데이트가 아직 적용 중이라는 피드백을 주기 위해 수량과 합계 모두에 대기 표시가 나타나요.
💡 부연 설명:
useOptimistic을 사용하면 서버 응답을 기다리지 않고 즉시 UI를 업데이트할 수 있어요. 서버 요청이 실패하면 자동으로 이전 상태로 롤백되기 때문에 사용자 경험이 훨씬 좋아져요!
dispatchAction 함수를 Action prop을 노출하는 컴포넌트에 전달하면, startTransition이나 useOptimistic을 직접 호출할 필요가 없어요.
이 예제는 QuantityStepper 컴포넌트의 increaseAction과 decreaseAction props를 사용하는 것을 보여줘요:
// 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 (
<div className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<QuantityStepper
value={count}
increaseAction={addAction}
decreaseAction={removeAction}
/>
</div>
<hr />
<Total quantity={count} isPending={isPending} />
</div>
);
}
async function updateCartAction(prevCount, actionPayload) {
switch (actionPayload.type) {
case 'ADD': {
return await addToCart(prevCount);
}
case 'REMOVE': {
return await removeFromCart(prevCount);
}
}
return prevCount;
}
// 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 (
<span className="stepper">
<span className="pending">{isPending && '🌀'}</span>
<span className="qty">{optimisticValue}</span>
<span className="buttons">
<button onClick={handleIncrease}>▲</button>
<button onClick={handleDecrease}>▼</button>
</span>
</span>
);
}
// Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity, isPending}) {
return (
<div className="row total">
<span>Total</span>
{isPending ? '🌀 Updating...' : formatter.format(quantity * 9999)}
</div>
);
}
// 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);
}
.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;
}
<QuantityStepper>가 transition, 대기 상태, 낙관적 업데이트를 내장 지원하기 때문에, Action에게 무엇을 변경할지만 알려주면 되고, 어떻게 변경할지는 알아서 처리돼요.
💡 부연 설명: 컴포넌트를 재사용 가능하게 만들 때 이런 패턴이 매우 유용해요. Action prop을 받아서 내부적으로 최적화 로직을 처리하도록 만들면, 사용하는 쪽에서는 훨씬 간단하게 사용할 수 있어요.
AbortController를 사용해서 대기 중인 Action을 취소할 수 있어요:
// 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 (
<div className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<QuantityStepper
value={count}
increaseAction={addAction}
decreaseAction={removeAction}
/>
</div>
<hr />
<Total quantity={count} isPending={isPending} />
</div>
);
}
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;
}
// 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 (
<span className="stepper">
<span className="pending">{isPending && '🌀'}</span>
<span className="qty">{optimisticValue}</span>
<span className="buttons">
<button onClick={handleIncrease}>▲</button>
<button onClick={handleDecrease}>▼</button>
</span>
</span>
);
}
// Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity, isPending}) {
return (
<div className="row total">
<span>Total</span>
{isPending ? '🌀 Updating...' : formatter.format(quantity * 9999)}
</div>
);
}
// api.js
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);
}
.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;
}
증가나 감소를 여러 번 클릭해보면, 몇 번을 클릭하든 합계가 1초 안에 업데이트된다는 걸 알 수 있어요. 이건 AbortController를 사용해서 이전 Action을 "완료"시키고 다음 Action을 진행할 수 있게 하기 때문이에요.
💡 부연 설명: 빠르게 여러 번 클릭해도 각 클릭마다 1초씩 기다리는 게 아니라, 이전 요청을 취소하고 최신 요청만 처리하니까 1초 안에 완료돼요!
Action을 취소하는 것이 항상 안전한 건 아니에요.
예를 들어, Action이 변경 작업(데이터베이스에 쓰기 같은)을 수행한다면, 네트워크 요청을 취소해도 서버 측 변경을 취소하지 못해요. 그래서 useActionState는 기본적으로 취소를 하지 않아요. 부수 효과를 안전하게 무시하거나 재시도할 수 있다는 걸 알고 있을 때만 안전해요.
💡 부연 설명: 예를 들어 검색 자동완성 같은 읽기 전용 작업이면 취소해도 안전하지만, 결제나 데이터 저장 같은 작업은 취소하면 데이터 불일치가 발생할 수 있어요!
<form> Action props와 함께 사용하기 {/use-with-a-form/}dispatchAction 함수를 <form>의 action prop으로 전달할 수 있어요.
이렇게 사용하면, React가 자동으로 제출을 Transition으로 감싸주기 때문에 직접 startTransition을 호출할 필요가 없어요. reducerAction은 이전 상태와 제출된 FormData를 받아요:
// 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 (
<form action={formAction} className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<span className="stepper">
<span className="pending">{isPending && '🌀'}</span>
<span className="qty">{optimisticCount}</span>
<span className="buttons">
<button type="submit" name="type" value="ADD">▲</button>
<button type="submit" name="type" value="REMOVE">▼</button>
</span>
</span>
</div>
<hr />
<Total quantity={count} isPending={isPending} />
</form>
);
}
async function updateCartAction(prevCount, formData) {
const type = formData.get('type');
switch (type) {
case 'ADD': {
return await addToCart(prevCount);
}
case 'REMOVE': {
return await removeFromCart(prevCount);
}
}
return prevCount;
}
// Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity, isPending}) {
return (
<div className="row total">
<span>Total</span>
{isPending ? '🌀 Updating...' : formatter.format(quantity * 9999)}
</div>
);
}
// 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);
}
.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;
}
이 예제에서 사용자가 스테퍼 화살표를 클릭하면, 버튼이 폼을 제출하고 useActionState가 폼 데이터와 함께 updateCartAction을 호출해요. 이 예제는 useOptimistic을 사용해서 서버가 업데이트를 확인하는 동안 새로운 수량을 즉시 보여줘요.
💡 부연 설명:
<form>의actionprop을 사용하면 폼 제출이 자동으로 Transition으로 처리돼요.FormData객체를 통해 입력값에 쉽게 접근할 수 있고, JavaScript가 없어도 폼이 작동하도록 만들 수 있어요 (점진적 향상).
서버 함수와 함께 사용하면, useActionState는 hydration(React가 서버 렌더링된 HTML에 연결하는 것)이 완료되기 전에도 서버의 응답을 보여줄 수 있어요. 동적 콘텐츠가 있는 페이지에서 점진적 향상을 위해 선택적 permalink 매개변수를 사용할 수도 있어요 (JavaScript가 로드되기 전에 폼이 작동하도록). 이건 보통 프레임워크가 자동으로 처리해줘요.
폼과 함께 Action을 사용하는 것에 대한 자세한 내용은 <form> 문서를 참조하세요.
useActionState로 에러를 처리하는 방법은 두 가지가 있어요.
백엔드의 "수량을 사용할 수 없음" 같은 알려진 유효성 검사 에러의 경우, reducerAction 상태의 일부로 반환해서 UI에 표시할 수 있어요.
undefined is not a function 같은 알 수 없는 에러의 경우, 에러를 던질 수 있어요. React는 모든 큐에 있는 Action을 취소하고 useActionState Hook에서 에러를 다시 던져서 가장 가까운 에러 경계를 보여줘요.
// 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) {
// API의 에러를 상태로 반환
return {...prevState, error: `Could not add quanitiy ${quantity}: ${result.error}`};
}
if (!isPending) {
// 첫 번째 디스패치에 대해 에러 상태를 지움
return {count: result.count, error: null};
}
// 새로운 count와 발생한 에러를 반환
return {count: result.count, error: prevState.error};
},
{
count: 0,
error: null,
}
);
function handleAdd(quantity) {
startTransition(() => {
dispatchAction(quantity);
});
}
return (
<div className="checkout">
<h2>Checkout</h2>
<div className="row">
<span>Eras Tour Tickets</span>
<span>
{isPending && '🌀 '}Qty: {state.count}
</span>
</div>
<div className="buttons">
<button onClick={() => handleAdd(1)}>Add 1</button>
<button onClick={() => handleAdd(10)}>Add 10</button>
<button onClick={() => handleAdd(NaN)}>Add NaN</button>
</div>
{state.error && <div className="error">{state.error}</div>}
<hr />
<Total quantity={state.count} isPending={isPending} />
</div>
);
}
export default function App() {
return (
<ErrorBoundary
fallbackRender={({resetErrorBoundary}) => (
<div className="checkout">
<h2>Something went wrong</h2>
<p>The action could not be completed.</p>
<button onClick={resetErrorBoundary}>Try again</button>
</div>
)}>
<Checkout />
</ErrorBoundary>
);
}
// Total.js
const formatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
});
export default function Total({quantity, isPending}) {
return (
<div className="row total">
<span>Total</span>
<span>
{isPending ? '🌀 Updating...' : formatter.format(quantity * 9999)}
</span>
</div>
);
}
// api.js
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};
}
.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;
}
{
"dependencies": {
"react": "19.0.0",
"react-dom": "19.0.0",
"react-scripts": "^5.0.0",
"react-error-boundary": "4.0.3"
},
"main": "/index.js"
}
이 예제에서 "Add 10"은 유효성 검사 에러를 반환하는 API를 시뮬레이션하는데, updateCartAction이 상태에 저장하고 인라인으로 표시해요. "Add NaN"은 유효하지 않은 count를 발생시켜서 updateCartAction이 에러를 던지고, 이게 useActionState를 통해 ErrorBoundary로 전파되어 리셋 UI를 보여줘요.
💡 부연 설명:
- 예상 가능한 에러 (유효성 검사 실패 등)는 상태로 반환해서 UI에 표시
- 예상치 못한 에러 (프로그래밍 에러 등)는 throw해서 Error Boundary로 처리
이렇게 구분해서 처리하면 사용자 경험이 훨씬 좋아져요!
isPending 플래그가 업데이트되지 않아요 {/ispending-not-updating/}dispatchAction을 (Action prop을 통하지 않고) 수동으로 호출한다면, startTransition으로 호출을 감싸야 해요:
import { useActionState, startTransition } from 'react';
function MyComponent() {
const [state, dispatchAction, isPending] = useActionState(myAction, null);
function handleClick() {
// ✅ 올바름: startTransition으로 감싸기
startTransition(() => {
dispatchAction();
});
}
// ...
}
dispatchAction이 Action prop으로 전달되면, React가 자동으로 Transition으로 감싸줘요.
useActionState를 사용하면, reducerAction은 첫 번째 인자로 추가적인 인자(이전 또는 초기 상태)를 받아요. 따라서 제출된 폼 데이터는 첫 번째가 아니라 두 번째 인자가 돼요.
// useActionState 없이
function action(formData) {
const name = formData.get('name');
}
// useActionState와 함께
function action(prevState, formData) {
const name = formData.get('name');
}
💡 부연 설명:
useActionState를 사용하면 함수 시그니처가 바뀐다는 걸 꼭 기억하세요! 첫 번째 인자는 항상 이전 상태고, 실제 폼 데이터나 payload는 두 번째 인자로 와요.
dispatchAction을 여러 번 호출했는데 일부가 실행되지 않는다면, 이전 dispatchAction 호출이 에러를 던졌기 때문일 수 있어요.
reducerAction이 에러를 던지면, React는 이후에 큐에 있는 모든 dispatchAction 호출을 건너뛰어요.
이를 처리하려면, reducerAction 내에서 에러를 캐치하고 에러를 던지는 대신 에러 상태를 반환하세요:
async function myReducerAction(prevState, data) {
try {
const result = await submitData(data);
return { success: true, data: result };
} catch (error) {
// ✅ 에러를 던지는 대신 에러 상태 반환
return { success: false, error: error.message };
}
}
💡 부연 설명: 에러를 던지면 그 이후의 모든 큐가 취소되니까, 에러를 상태로 관리해서 각 Action이 독립적으로 처리되도록 만드는 게 좋아요!
useActionState는 내장된 리셋 함수를 제공하지 않아요. 상태를 리셋하려면, reducerAction이 리셋 신호를 처리하도록 설계할 수 있어요:
const initialState = { name: '', error: null };
async function formAction(prevState, payload) {
// 리셋 처리
if (payload === null) {
return initialState;
}
// 일반 액션 로직
const result = await submitData(payload);
return result;
}
function MyComponent() {
const [state, dispatchAction, isPending] = useActionState(formAction, initialState);
function handleReset() {
startTransition(() => {
dispatchAction(null); // null을 전달해서 리셋 트리거
});
}
// ...
}
또는, useActionState를 사용하는 컴포넌트에 key prop을 추가해서 새로운 상태로 강제로 다시 마운트시키거나, <form> action prop을 사용할 수 있어요. 폼은 제출 후 자동으로 리셋돼요.
💡 부연 설명:
- 방법 1: 특별한 payload(예: null)를 보내서 리셋 처리
- 방법 2: key prop 변경으로 컴포넌트 재마운트
- 방법 3: form의 자동 리셋 기능 활용
흔한 실수는 dispatchAction을 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.
이 에러는 dispatchAction이 Transition 안에서 실행되어야 하는데 그렇지 않을 때 발생해요:
function MyComponent() {
const [state, dispatchAction, isPending] = useActionState(myAsyncAction, null);
function handleClick() {
// ❌ 잘못됨: Transition 밖에서 dispatchAction 호출
dispatchAction();
}
// ...
}
수정하려면, startTransition으로 호출을 감싸세요:
import { useActionState, startTransition } from 'react';
function MyComponent() {
const [state, dispatchAction, isPending] = useActionState(myAsyncAction, null);
function handleClick() {
// ✅ 올바름: startTransition으로 감싸기
startTransition(() => {
dispatchAction();
});
}
// ...
}
또는 dispatchAction을 Action prop으로 전달하면, Transition 안에서 호출돼요:
function MyComponent() {
const [state, dispatchAction, isPending] = useActionState(myAsyncAction, null);
// ✅ 올바름: action prop이 자동으로 Transition으로 감싸줌
return <Button action={dispatchAction}>...</Button>;
}
렌더링 중에는 dispatchAction을 호출할 수 없어요:
Cannot update action state while rendering.
dispatchAction을 호출하면 상태 업데이트가 스케줄되고, 이게 다시 렌더링을 트리거해서, 또 dispatchAction을 호출하는 무한 루프가 발생하기 때문이에요.
function MyComponent() {
const [state, dispatchAction, isPending] = useActionState(myAction, null);
// ❌ 잘못됨: 렌더링 중에 dispatchAction 호출
dispatchAction();
// ...
}
수정하려면, 사용자 이벤트(폼 제출이나 버튼 클릭 같은)에 대한 응답으로만 dispatchAction을 호출하세요.
💡 부연 설명: 렌더링 중에는 어떤 상태 업데이트도 하면 안 돼요! 항상 이벤트 핸들러나 Effect 안에서만 상태를 업데이트해야 해요.