| title | useActionState |
|---|
useActionState๋ Action์ ์ฌ์ฉํด ์ฌ์ด๋ ์ดํํธ๋ฅผ ๋๋ฐํ ์ํ ์
๋ฐ์ดํธ๋ฅผ ํ ์ ์๊ฒ ํด์ฃผ๋ React Hook์
๋๋ค.
const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState, permalink?);์ปดํฌ๋ํธ์ ์ต์์ ๋ ๋ฒจ์์ useActionState๋ฅผ ํธ์ถํ์ฌ Action์ ๊ฒฐ๊ณผ์ ๋ํ ์ํ๋ฅผ ์์ฑํฉ๋๋ค.
import { useActionState } from 'react';
function reducerAction(previousState, actionPayload) {
// ...
}
function MyCart({initialState}) {
const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState);
// ...
}์๋์์ ๋ ๋ง์ ์์๋ฅผ ํ์ธํ์ธ์.
reducerAction: Action์ด ํธ๋ฆฌ๊ฑฐ๋ ๋ ํธ์ถ๋ ํจ์์ ๋๋ค. ํธ์ถ๋ ๋ ์ฒซ ๋ฒ์งธ ์ธ์๋ก ์ด์ ์ํ(์ด๊ธฐ์๋ ์ ๊ณตํinitialState, ์ดํ์๋ ์ด์ ๋ฐํ๊ฐ)๋ฅผ ๋ฐ๊ณ , ๋ ๋ฒ์งธ ์ธ์๋กdispatchAction์ ์ ๋ฌ๋actionPayload๋ฅผ ๋ฐ์ต๋๋ค.initialState: ์ํ์ ์ด๊น๊ฐ์ ๋๋ค. React๋dispatchAction์ด ์ฒ์ ํธ์ถ๋ ์ดํ์๋ ์ด ์ธ์๋ฅผ ๋ฌด์ํฉ๋๋ค.- ์ ํ ์ฌํญ
permalink: ์ด ํผ์ด ์์ ํ๋ ๊ณ ์ ํ ํ์ด์ง URL์ ํฌํจํ๋ ๋ฌธ์์ด์ ๋๋ค.- ์ ์ง์ ํฅ์(progressive enhancement)๊ณผ ํจ๊ป React Server Components๋ฅผ ์ฌ์ฉํ๋ ํ์ด์ง์์ ์ฌ์ฉํ๊ธฐ ์ํ ์ฉ๋์ ๋๋ค.
reducerAction์ด Server Function์ด๊ณ JavaScript ๋ฒ๋ค์ด ๋ก๋๋๊ธฐ ์ ์ ํผ์ด ์ ์ถ๋๋ฉด, ๋ธ๋ผ์ฐ์ ๋ ํ์ฌ ํ์ด์ง์ URL์ด ์๋ ์ง์ ๋ permalink URL๋ก ์ด๋ํฉ๋๋ค.
useActionState๋ ์ ํํ ์ธ ๊ฐ์ ๊ฐ์ ๊ฐ์ง ๋ฐฐ์ด์ ๋ฐํํฉ๋๋ค.
- ํ์ฌ ์ํ. ์ฒซ ๋ฒ์งธ ๋ ๋๋ง ์ค์๋ ์ ๋ฌํ
initialState์ ์ผ์นํฉ๋๋ค.dispatchAction์ด ํธ์ถ๋ ํ์๋reducerAction์ด ๋ฐํํ ๊ฐ๊ณผ ์ผ์นํ๊ฒ ๋ฉ๋๋ค. - Actions ๋ด๋ถ์์ ํธ์ถํ๋
dispatchActionํจ์. - ์ด Hook์ ๋ํด ๋์คํจ์น๋ Action์ด ๋๊ธฐ ์ค(pending)์ธ์ง ์๋ ค์ฃผ๋
isPendingํ๋๊ทธ.
useActionState๋ Hook์ด๋ฏ๋ก ์ปดํฌ๋ํธ์ ์ต์์ ๋ ๋ฒจ ๋๋ ์ง์ ๋ง๋ ์ปค์คํ Hook ์์์๋ง ํธ์ถํ ์ ์์ต๋๋ค. ๋ฐ๋ณต๋ฌธ์ด๋ ์กฐ๊ฑด๋ฌธ ๋ด๋ถ์์๋ ํธ์ถํ ์ ์์ต๋๋ค. ํ์ํ๋ค๋ฉด ์ ์ปดํฌ๋ํธ๋ฅผ ์ถ์ถํ์ฌ ์ํ๋ฅผ ๊ทธ ์์ผ๋ก ์ฎ๊ธฐ์ธ์.- React๋
dispatchAction์ ๋ํ ์ฌ๋ฌ ํธ์ถ์ ์ฐจ๋ก๋๋ก ํ์ ๋ฃ๊ณ ์คํํฉ๋๋ค.reducerAction์ ๊ฐ ํธ์ถ์ ์ด์ ํธ์ถ์ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ์ต๋๋ค. dispatchActionํจ์๋ ์์ ์ ์ธ ์๋ณ์๋ฅผ ๊ฐ์ง๋๋ค. ๋ฐ๋ผ์ Effect ์์กด์ฑ ๋ฐฐ์ด์์ ์๋ต๋๋ ๊ฒฝ์ฐ๊ฐ ๋ง์ง๋ง, ํฌํจํ๋๋ผ๋ Effect๊ฐ ์คํ๋์ง๋ ์์ต๋๋ค. ๋ฆฐํฐ๊ฐ ์ค๋ฅ ์์ด ์์กด์ฑ์ ์๋ตํ ์ ์๊ฒ ํด์ค๋ค๋ฉด ์๋ตํ๋ ๊ฒ์ด ์์ ํฉ๋๋ค. Effect ์์กด์ฑ ์ ๊ฑฐ์ ๋ํด ๋ ์์๋ณด๊ธฐ.permalink์ต์ ์ ์ฌ์ฉํ ๋, ๋์ ํ์ด์ง์๋ ๋์ผํ ํผ ์ปดํฌ๋ํธ(๋์ผํreducerAction๊ณผpermalinkํฌํจ)๊ฐ ๋ ๋๋ง๋์ด React๊ฐ ์ํ๋ฅผ ์ ๋ฌํ๋ ๋ฐฉ๋ฒ์ ์ ์ ์๋๋ก ํด์ผ ํฉ๋๋ค. ํ์ด์ง๊ฐ ์ํธ์์ฉ ๊ฐ๋ฅํด์ง๋ฉด ์ด ๋งค๊ฐ๋ณ์๋ ์๋ฌด๋ฐ ์ํฅ์ ๋ฏธ์น์ง ์์ต๋๋ค.- Server Functions๋ฅผ ์ฌ์ฉํ ๋,
initialState๋ ์ง๋ ฌํ ๊ฐ๋ฅ(serializable)ํด์ผ ํฉ๋๋ค(์ผ๋ฐ ๊ฐ์ฒด, ๋ฐฐ์ด, ๋ฌธ์์ด, ์ซ์์ ๊ฐ์ ๊ฐ). dispatchAction์ด ์ค๋ฅ๋ฅผ ๋์ง๋ฉด, React๋ ๋๊ธฐ ์ค์ธ ๋ชจ๋ Action์ ์ทจ์ํ๊ณ ๊ฐ์ฅ ๊ฐ๊น์ด Error Boundary๋ฅผ ํ์ํฉ๋๋ค.- ์งํ ์ค์ธ Action์ด ์ฌ๋ฌ ๊ฐ ์๋ ๊ฒฝ์ฐ, React๋ ์ด๋ค์ ์ผ๊ด ์ฒ๋ฆฌ(batch)ํฉ๋๋ค. ์ด๋ ํฅํ ๋ฆด๋ฆฌ์ค์์ ์ ๊ฑฐ๋ ์ ์๋ ํ๊ณ์ ์ ๋๋ค.
dispatchAction์ ๋ฐ๋์ Action ์์์ ํธ์ถ๋์ด์ผ ํฉ๋๋ค.
startTransition์ผ๋ก ๊ฐ์ธ๊ฑฐ๋, Action prop์ผ๋ก ์ ๋ฌํ ์ ์์ต๋๋ค. ํด๋น ์ค์ฝํ ๋ฐ์์ ํธ์ถํ๋ฉด Transition์ ์ผ๋ถ๋ก ์ทจ๊ธ๋์ง ์์ผ๋ฉฐ ๊ฐ๋ฐ ๋ชจ๋์์ ์๋ฌ ๋ก๊ทธ๋ฅผ ๋จ๊น๋๋ค.
useActionState์ ์ ๋ฌ๋ reducerAction ํจ์๋ ์ด์ ์ํ๋ฅผ ๋ฐ๊ณ ์๋ก์ด ์ํ๋ฅผ ๋ฐํํฉ๋๋ค.
useReducer์ ๋ฆฌ๋์์๋ ๋ฌ๋ฆฌ reducerAction์ ๋น๋๊ธฐ์ ์ผ ์ ์์ผ๋ฉฐ ์ฌ์ด๋ ์ดํํธ๋ฅผ ์ํํ ์ ์์ต๋๋ค.
async function reducerAction(previousState, actionPayload) {
const newState = await post(actionPayload);
return newState;
}dispatchAction์ ํธ์ถํ ๋๋ง๋ค React๋ actionPayload์ ํจ๊ป reducerAction์ ํธ์ถํฉ๋๋ค. ๋ฆฌ๋์๋ ๋ฐ์ดํฐ๋ฅผ ๊ฒ์(posting)ํ๋ ๋ฑ์ ์ฌ์ด๋ ์ดํํธ๋ฅผ ์ํํ๊ณ ์ ์ํ๋ฅผ ๋ฐํํฉ๋๋ค. dispatchAction์ด ์ฌ๋ฌ ๋ฒ ํธ์ถ๋๋ฉด React๋ ์ด๋ฅผ ์์๋๋ก ํ์ ๋ฃ๊ณ ์คํํ์ฌ ์ด์ ํธ์ถ์ ๊ฒฐ๊ณผ๊ฐ ํ์ฌ ํธ์ถ์ previousState๋ก ์ ๋ฌ๋๋๋ก ํฉ๋๋ค.
-
previousState: ๋ง์ง๋ง ์ํ์ ๋๋ค. ์ฒ์์๋initialState์ ๋์ผํฉ๋๋ค.dispatchAction์ด ์ฒ์ ํธ์ถ๋ ์ดํ์๋ ๋ง์ง๋ง์ผ๋ก ๋ฐํ๋ ์ํ์ ๊ฐ์์ง๋๋ค. -
์ ํ ์ฌํญ
actionPayload:dispatchAction์ ์ ๋ฌ๋ ์ธ์์ ๋๋ค. ์ด๋ค ํ์ ์ ๊ฐ์ด๋ ๋ ์ ์์ต๋๋ค.useReducer์ ๊ด๋ก์ ์ ์ฌํ๊ฒ ์ผ๋ฐ์ ์ผ๋ก Action์ ์๋ณํ๋typeํ๋กํผํฐ์ ์ถ๊ฐ ์ ๋ณด๊ฐ ๋ด๊ธด ์ ํ์ ํ๋กํผํฐ๋ฅผ ๊ฐ์ง ๊ฐ์ฒด์ ๋๋ค.
reducerAction์ ์ ์ํ๋ฅผ ๋ฐํํ๊ณ ํด๋น ์ํ๋ก ๋ฆฌ๋ ๋๋งํ๊ธฐ ์ํ Transition์ ํธ๋ฆฌ๊ฑฐํฉ๋๋ค.
reducerAction์ ๋๊ธฐ์ ์ผ ์๋ ์๊ณ ๋น๋๊ธฐ์ ์ผ ์๋ ์์ต๋๋ค. ์๋ฆผ์ ํ์ํ๋ ๋ฑ์ ๋๊ธฐ์ Action์ด๋ ์๋ฒ์ ์ ๋ฐ์ดํธ๋ฅผ ๊ฒ์ํ๋ ๋ฑ์ ๋น๋๊ธฐ์ Action์ ์ํํ ์ ์์ต๋๋ค.reducerAction์ ์ฌ์ด๋ ์ดํํธ๋ฅผ ํ์ฉํ๋๋ก ์ค๊ณ๋์๊ธฐ ๋๋ฌธ์<StrictMode>์์ ๋ ๋ฒ ํธ์ถ๋์ง ์์ต๋๋ค.reducerAction์ ๋ฐํ ํ์ ์initialState์ ํ์ ๊ณผ ์ผ์นํด์ผ ํฉ๋๋ค. TypeScript๊ฐ ๋ถ์ผ์น๋ฅผ ์ถ๋ก ํ ๊ฒฝ์ฐ, ์ํ ํ์ ์ ๋ช ์์ ์ผ๋ก ์ง์ ํด์ผ ํ ์๋ ์์ต๋๋ค.reducerAction์await์ดํ์ ์ํ๋ฅผ ์ค์ ํ๋ ๊ฒฝ์ฐ, ํ์ฌ๋ ์ํ ์ ๋ฐ์ดํธ๋ฅผ ์ถ๊ฐ์ ์ธstartTransition์ผ๋ก ๊ฐ์ธ์ผ ํฉ๋๋ค. ์์ธํ ์ ๋ณด๋ startTransition ๋ฌธ์๋ฅผ ์ฐธ๊ณ ํ์ธ์.- Server Functions๋ฅผ ์ฌ์ฉํ ๋,
actionPayload๋ ์ง๋ ฌํ ๊ฐ๋ฅ(serializable)ํด์ผ ํฉ๋๋ค(์ผ๋ฐ ๊ฐ์ฒด, ๋ฐฐ์ด, ๋ฌธ์์ด, ์ซ์์ ๊ฐ์ ๊ฐ).
useActionState์ ์ ๋ฌ๋ ํจ์๋ ๋ค์๊ณผ ๊ฐ์ ์ด์ ๋ก reducer action์ด๋ผ๊ณ ๋ถ๋ฆฝ๋๋ค.
useReducer์ ๊ฐ์ด ์ด์ ์ํ๋ฅผ ์๋ก์ด ์ํ๋ก *์ถ์(reduce)*ํ๊ธฐ ๋๋ฌธ์ ๋๋ค.- Transition ๋ด๋ถ์์ ํธ์ถ๋๊ณ ์ฌ์ด๋ ์ดํํธ๋ฅผ ์ํํ ์ ์๋ Action์ด๊ธฐ ๋๋ฌธ์ ๋๋ค.
๊ฐ๋
์ ์ผ๋ก useActionState๋ useReducer์ ๊ฐ์ง๋ง ๋ฆฌ๋์ ์์์ ์ฌ์ด๋ ์ดํํธ๋ฅผ ์ํํ ์ ์๋ค๋ ์ ์ด ๋ค๋ฆ
๋๋ค.
์ปดํฌ๋ํธ์ ์ต์์ ๋ ๋ฒจ์์ useActionState๋ฅผ ํธ์ถํ์ฌ Action์ ๊ฒฐ๊ณผ์ ๋ํ ์ํ๋ฅผ ์์ฑํ์ธ์.
import { useActionState } from 'react';
async function addToCartAction(prevCount) {
// ...
}
function Counter() {
const [count, dispatchAction, isPending] = useActionState(addToCartAction, 0);
// ...
}useActionState๋ ์ ํํ ์ธ ๊ฐ์ ํญ๋ชฉ์ด ํฌํจ๋ ๋ฐฐ์ด์ ๋ฐํํฉ๋๋ค.
- ์ ๊ณตํ ์ด๊ธฐ ์ํ๋ก ์ค์ ๋ ํ์ฌ ์ํ.
reducerAction์ ํธ๋ฆฌ๊ฑฐํ ์ ์๊ฒ ํด์ฃผ๋ Action ๋์คํจ์ฒ.- Action์ด ์งํ ์ค์ธ์ง ์๋ ค์ฃผ๋ ๋ณด๋ฅ ์ํ(pending state).
addToCartAction์ ํธ์ถํ๋ ค๋ฉด Action ๋์คํจ์ฒ๋ฅผ ํธ์ถํ์ธ์. React๋ ์ด์ ๊ฐ์์ ํจ๊ป addToCartAction ํธ์ถ์ ํ์ ๋ฃ์ต๋๋ค.
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>
);
}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>
);
}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๋ ๋ชจ๋ ํฐ์ผ์ด ์ถ๊ฐ๋ ๋๊น์ง ๋ณด๋ฅ ์ํ๋ฅผ ํ์ํ๊ณ , ์ต์ข
์ํ๋ก ๋ฆฌ๋ ๋๋ง์ ์งํํฉ๋๋ค.
"Add Ticket"์ ์ฌ๋ฌ ๋ฒ ํด๋ฆญํด ๋ณด์ธ์. ํด๋ฆญํ ๋๋ง๋ค ์๋ก์ด addToCartAction์ด ํ์ ์์
๋๋ค. ์ธ์์ ์ธ 1์ด์ ์ง์ฐ ์๊ฐ์ด ์์ผ๋ฏ๋ก 4๋ฒ ํด๋ฆญํ๋ฉด ์๋ฃํ๋ ๋ฐ ์ฝ 4์ด๊ฐ ๊ฑธ๋ฆฝ๋๋ค.
์ด๋ useActionState ์ค๊ณ์ ์๋๋ ๋์์
๋๋ค.
React๋ ๋ค์ addToCartAction ํธ์ถ์ prevCount๋ฅผ ์ ๋ฌํ๊ธฐ ์ํด ์ด์ addToCartAction์ ๊ฒฐ๊ณผ๋ฅผ ๊ธฐ๋ค๋ ค์ผ ํฉ๋๋ค. ์ฆ, ๋ค์ Action์ ํธ์ถํ๊ธฐ ์ ์ ์ด์ Action์ด ๋๋ ๋๊น์ง ๊ธฐ๋ค๋ ค์ผ ํฉ๋๋ค.
์ผ๋ฐ์ ์ผ๋ก useOptimistic๊ณผ ํจ๊ป ์ฌ์ฉํ์ฌ ์ด ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ ์ ์์ง๋ง, ๋ ๋ณต์กํ ๊ฒฝ์ฐ์๋ ๋๊ธฐ ์ค์ธ Action ์ทจ์ํ๊ธฐ๋ฅผ ๊ณ ๋ คํ๊ฑฐ๋ useActionState๋ฅผ ์ฌ์ฉํ์ง ์๋ ๊ฒ์ ๊ณ ๋ คํด ๋ณผ ์ ์์ต๋๋ค.
์ฌ๋ฌ ํ์
์ ์ฒ๋ฆฌํ๊ธฐ ์ํด dispatchAction์ ์ธ์๋ฅผ ์ ๋ฌํ ์ ์์ต๋๋ค.
๊ด๋ก์ ๋ณดํต switch ๋ฌธ์ผ๋ก ์์ฑํฉ๋๋ค. switch์ ๊ฐ case์์ ๋ค์ ์ํ๋ฅผ ๊ณ์ฐํ๊ณ ๋ฐํํฉ๋๋ค. ์ธ์๋ ์ด๋ค ํํ๋ ๊ฐ์ง ์ ์์ง๋ง Action์ ์๋ณํ๋ type ํ๋กํผํฐ๋ฅผ ๊ฐ์ง ๊ฐ์ฒด๋ฅผ ์ ๋ฌํ๋ ๊ฒ์ด ์ผ๋ฐ์ ์
๋๋ค.
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;
}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>
);
}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๊ฐ ํธ์ถ๋ฉ๋๋ค.
์ด ์์์์๋ Action์ ๋ณด๋ฅ ์ํ(pending state)๋ฅผ ์ฌ์ฉํ์ฌ ์๋๊ณผ ์ดํฉ์ ๋์ฒดํ๊ณ ์์ต๋๋ค. ์๋์ ์ฆ์ ์
๋ฐ์ดํธํ๋ ๋ฑ ์ฆ๊ฐ์ ์ธ ํผ๋๋ฐฑ์ ์ ๊ณตํ๋ ค๋ฉด useOptimistic์ ์ฌ์ฉํ ์ ์์ต๋๋ค.
์ด ์์๊ฐ useReducer์ ๋งค์ฐ ๋น์ทํด ๋ณด์ผ ์ ์์ง๋ง ๊ทธ ๋ชฉ์ ์ด ๋ค๋ฆ
๋๋ค.
-
UI์ ์ํ๋ฅผ ๊ด๋ฆฌํ๋ ค๋ฉด
useReducer๋ฅผ ์ฌ์ฉํ์ธ์. ๋ฆฌ๋์๋ ์์(pure)ํด์ผ ํฉ๋๋ค. -
Action์ ์ํ๋ฅผ ๊ด๋ฆฌํ๋ ค๋ฉด
useActionState๋ฅผ ์ฌ์ฉํ์ธ์. ๋ฆฌ๋์๋ ์ฌ์ด๋ ์ดํํธ๋ฅผ ์ํํ ์ ์์ต๋๋ค.
useActionState๋ ์ฌ์ฉ์ Action์ผ๋ก ์ธํ ์ฌ์ด๋ ์ดํํธ๋ฅผ ์ฒ๋ฆฌํ๊ธฐ ์ํ useReducer๋ผ๊ณ ์๊ฐํ ์ ์์ต๋๋ค. ์ด์ Action์ ๊ธฐ๋ฐ์ผ๋ก ๋ค์ ์คํํ Action์ ๊ณ์ฐํ๊ธฐ ๋๋ฌธ์ ํธ์ถ์ ์ฐจ๋ก๋๋ก ๋๊ธฐ์ด์ ์ถ๊ฐํด์ผ ํฉ๋๋ค. ์ฌ๋ฌ Action์ ๋ณ๋ ฌ๋ก ์ํํ๋ ค๋ฉด useState์ useTransition์ ์ง์ ์ฌ์ฉํ์ธ์.
useActionState๋ฅผ useOptimistic๊ณผ ๊ฒฐํฉํ์ฌ ์ฆ๊ฐ์ ์ธ UI ํผ๋๋ฐฑ์ ํ์ํ ์ ์์ต๋๋ค.
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;
}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>
);
}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์ ํ์ ๋ฃ์ต๋๋ค. ์๋๊ณผ ์ดํฉ ๋ชจ๋์ ๋๊ธฐ ์ํ ํ์๊ธฐ๋ฅผ ๋์ ์ฌ์ฉ์์ ์
๋ฐ์ดํธ๊ฐ ์์ง ์ ์ฉ ์ค์ด๋ผ๋ ํผ๋๋ฐฑ์ ์ ๊ณตํฉ๋๋ค.
Action prop์ ๋
ธ์ถํ๋ ์ปดํฌ๋ํธ์ dispatchAction ํจ์๋ฅผ ์ ๋ฌํ๋ ๊ฒฝ์ฐ startTransition์ด๋ useOptimistic์ ์ง์ ํธ์ถํ ํ์๊ฐ ์์ต๋๋ค.
์ด ์์๋ QuantityStepper ์ปดํฌ๋ํธ์ increaseAction ๋ฐ decreaseAction prop์ ์ฌ์ฉํ๋ ๋ฐฉ๋ฒ์ ๋ณด์ฌ์ค๋๋ค.
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;
}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>
);
}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>
);
}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, ๋ณด๋ฅ ์ํ(pending state), ์นด์ดํธ๋ฅผ ๋๊ด์ ์ผ๋ก ์
๋ฐ์ดํธํ๋ ๊ธฐ๋ฅ์ด ์ด๋ฏธ ๋ด์ฅ๋์ด ์๊ธฐ ๋๋ฌธ์ Action์๊ฒ *๋ฌด์(what)*์ ๋ฐ๊ฟ์ง๋ง ์๋ ค์ฃผ๋ฉด ์ด๋ป๊ฒ(how) ๋ฐ๊ฟ์ง๋ ์ปดํฌ๋ํธ๊ฐ ๋์ ์ฒ๋ฆฌํด ์ค๋๋ค.
AbortController๋ฅผ ์ฌ์ฉํ์ฌ ๋ณด๋ฅ ์ค์ธ Action์ ์ทจ์ํ ์ ์์ต๋๋ค.
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;
}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>
);
}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>
);
}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์ด ๋ฐ๋ก ์งํ๋๋๋ก ๋ง๋ค๊ธฐ ๋๋ฌธ์ ์๋ํฉ๋๋ค.
Action์ ์ค๋จ(aborting)ํ๋ ๊ฒ์ด ํญ์ ์์ ํ์ง๋ ์์ต๋๋ค.
์๋ฅผ ๋ค์ด, Action์ด ๋ฐ์ดํฐ๋ฒ ์ด์ค ์ฐ๊ธฐ์ ๊ฐ์ ๋ณ๊ฒฝ ์์
(mutation)์ ์ํํ๋ ๊ฒฝ์ฐ, ๋คํธ์ํฌ ์์ฒญ์ ์ค๋จํ๋ค๊ณ ํด์ ์๋ฒ ์ธก ๋ณ๊ฒฝ์ด ์ทจ์๋์ง๋ ์์ต๋๋ค. ์ด๊ฒ์ด useActionState๊ฐ ๊ธฐ๋ณธ์ ์ผ๋ก ์ค๋จ ๊ธฐ๋ฅ์ ์ฌ์ฉํ์ง ์๋ ์ด์ ์
๋๋ค. ์ฌ์ด๋ ์ดํํธ๋ฅผ ์์ ํ๊ฒ ๋ฌด์ํ๊ฑฐ๋ ์ฌ์๋ํ ์ ์๋ค๊ณ ํ์ ํ ๋๋ง ์ฌ์ฉํ๋ ๊ฒ์ด ์์ ํฉ๋๋ค.
dispatchAction ํจ์๋ฅผ <form>์ action prop์ผ๋ก ์ ๋ฌํ ์ ์์ต๋๋ค.
์ด ๋ฐฉ์์ผ๋ก ์ฌ์ฉํ๋ฉด React๊ฐ ์๋์ผ๋ก ํผ ์ ์ถ์ Transition์ผ๋ก ๊ฐ์ธ์ฃผ๊ธฐ ๋๋ฌธ์ startTransition์ ์ง์ ํธ์ถํ ํ์๊ฐ ์์ต๋๋ค. reducerAction์ ์ด์ ์ํ์ ์ ์ถ๋ FormData๋ฅผ ๋ฐ๊ฒ ๋ฉ๋๋ค.
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;
}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>
);
}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์ ํ์ฉํ์ฌ ์๋ฒ๊ฐ ์
๋ฐ์ดํธ๋ฅผ ํ์ธํ๋ ๋์ ์๋ก์ด ์๋์ ์ฆ์ ํ์ํฉ๋๋ค.
Server Function๊ณผ ํจ๊ป ์ฌ์ฉํ ๋, useActionState๋ ํ์ด๋๋ ์ด์
(React๊ฐ ์๋ฒ ๋ ๋๋ง HTML์ ๋ถ์ฐฉ๋๋ ๊ณผ์ )์ด ์๋ฃ๋๊ธฐ ์ ์ ์๋ฒ์ ์๋ต์ ํ์ํ ์ ์๊ฒ ํด์ค๋๋ค. ๋์ ์ฝํ
์ธ ๊ฐ ์๋ ํ์ด์ง์ ๊ฒฝ์ฐ ์ ์ง์ ํฅ์(JavaScript๊ฐ ๋ก๋๋๊ธฐ ์ ์ ํผ์ด ์๋ํ๋๋ก ํ์ฉ)์ ์ํด ์ ํ์ ์ธ permalink ๋งค๊ฐ๋ณ์๋ฅผ ์ฌ์ฉํ ์๋ ์์ต๋๋ค. ์ด๋ ์ผ๋ฐ์ ์ผ๋ก ํ๋ ์์ํฌ๊ฐ ๋์ ์ฒ๋ฆฌํด ์ค๋๋ค.
ํผ๊ณผ ํจ๊ป Action์ ์ฌ์ฉํ๋ ๋ฐฉ๋ฒ์ ๋ํ ์์ธํ ๋ด์ฉ์ <form> ๋ฌธ์๋ฅผ ์ฐธ์กฐํ์ธ์.
useActionState๋ก ์ค๋ฅ๋ฅผ ์ฒ๋ฆฌํ๋ ๋ฐฉ๋ฒ์๋ ๋ ๊ฐ์ง๊ฐ ์์ต๋๋ค.
๋ฐฑ์๋์์ ์ค๋ "์๋ ๋ถ์กฑ" ๊ฐ์ ์๋ ค์ง ์ ํจ์ฑ ๊ฒ์ฌ ์ค๋ฅ์ ๊ฒฝ์ฐ, ์ด๋ฅผ reducerAction ์ํ์ ์ผ๋ถ๋ก ๋ฐํํ์ฌ UI์ ํ์ํ ์ ์์ต๋๋ค.
undefined is not a function ๊ฐ์ ์ ์ ์๋ ์ค๋ฅ์ ๊ฒฝ์ฐ์๋ ์๋ฌ๋ฅผ ์ง์ ๋์ง(throw) ์ ์์ต๋๋ค. React๋ ํ์ ๋๊ธฐ ์ค์ธ ๋ชจ๋ Action์ ์ทจ์ํ๊ณ useActionState ํ
์์ ์ค๋ฅ๋ฅผ ๋ค์ ๋ฐ์์์ผ ๊ฐ์ฅ ๊ฐ๊น์ด Error Boundary๋ฅผ ํ์ํ๊ฒ ๋ฉ๋๋ค.
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};
}
// ์ ์นด์ดํธ์ ๋ฐ์ํ ์ค๋ฅ๋ฅผ ๋ฐํํฉ๋๋ค.
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>
);
}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>
);
}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"์ ์ ํจํ์ง ์์ ์นด์ดํธ๋ฅผ ๋ง๋ค์ด updateCartAction์ด ์๋ฌ๋ฅผ ๋์ง๊ฒ ๋ง๋ค๊ณ , ์ด ์๋ฌ๋ useActionState๋ฅผ ํตํด ErrorBoundary๋ก ์ ํ๋์ด ๋ฆฌ์
(reset) UI๋ฅผ ํ์ํ๊ฒ ๋ฉ๋๋ค.
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');
}dispatchAction์ ์ฌ๋ฌ ๋ฒ ํธ์ถํ๋๋ฐ ๊ทธ์ค ์ผ๋ถ๊ฐ ์คํ๋์ง ์๋๋ค๋ฉด, ์ด๋ ์ด์ dispatchAction ํธ์ถ์์ ์ค๋ฅ๊ฐ ๋ฐ์ํ๊ธฐ ๋๋ฌธ์ผ ์ ์์ต๋๋ค.
reducerAction์ด ์๋ฌ๋ฅผ ๋์ง๋ฉด React๋ ์ดํ ํ์ ์๋ ๋ชจ๋ dispatchAction ํธ์ถ์ ๊ฑด๋๋๋๋ค.
์ด ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๋ ค๋ฉด reducerAction ์์์ ์ค๋ฅ๋ฅผ ํฌ์ฐฉ(catch)ํ๊ณ ์๋ฌ๋ฅผ ๋์ง๋ ๋์ ์ค๋ฅ ์ํ๋ฅผ ๋ฐํํ์ธ์.
async function myReducerAction(prevState, data) {
try {
const result = await submitData(data);
return { success: true, data: result };
} catch (error) {
// โ
์๋ฌ๋ฅผ ๋์ง๋ ๋์ ์ค๋ฅ ์ํ๋ฅผ ๋ฐํํฉ๋๋ค
return { success: false, error: error.message };
}
}useActionState๋ ๋ด์ฅ๋ ์ด๊ธฐํ(reset) ํจ์๋ฅผ ์ ๊ณตํ์ง ์์ต๋๋ค. ์ํ๋ฅผ ๋ฆฌ์
ํ๋ ค๋ฉด reducerAction์ด ๋ฆฌ์
์ ํธ๋ฅผ ์ฒ๋ฆฌํ๋๋ก ์ค๊ณํ ์ ์์ต๋๋ค.
const initialState = { name: '', error: null };
async function formAction(prevState, payload) {
// ๋ฆฌ์
์ฒ๋ฆฌ
if (payload === null) {
return initialState;
}
// ์ผ๋ฐ์ ์ธ Action ๋ก์ง
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์ ์ฌ์ฉํ ์๋ ์์ต๋๋ค.
"An async function with useActionState was called outside of a transition." ์๋ฌ๊ฐ ๋ฐ์ํฉ๋๋ค {/async-function-outside-transition/}
Transition ์์์ dispatchAction์ ํธ์ถํ๋ ๊ฒ์ ๋น ๋จ๋ฆฌ๋ ์ค์๋ฅผ ์์ฃผ ํฉ๋๋ค.
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>;
}"Cannot update action state while rendering" ์๋ฌ๊ฐ ๋ฐ์ํฉ๋๋ค {/cannot-update-during-render/}
๋ ๋๋ง ์ค์๋ dispatchAction์ ํธ์ถํ ์ ์์ต๋๋ค.
Cannot update action state while rendering.
์ด๋ ๊ฒ ํ๋ฉด dispatchAction์ด ์ํ ์
๋ฐ์ดํธ๋ฅผ ์์ฝํ๊ณ ์ด๋ก ์ธํด ๋ฆฌ๋ ๋๋ง์ด ํธ๋ฆฌ๊ฑฐ๋๋ฉฐ ๋ค์ dispatchAction์ ํธ์ถํ๋ ๋ฌดํ ๋ฃจํ๊ฐ ๋ฐ์ํฉ๋๋ค.
function MyComponent() {
const [state, dispatchAction, isPending] = useActionState(myAction, null);
// โ ์๋ชป๋ ๋ฐฉ๋ฒ: ๋ ๋๋ง ์ค์ dispatchAction ํธ์ถ
dispatchAction();
// ...
}์ด ๋ฌธ์ ๋ฅผ ํด๊ฒฐํ๋ ค๋ฉด ํผ ์ ์ถ์ด๋ ๋ฒํผ ํด๋ฆญ๊ณผ ๊ฐ์ ์ฌ์ฉ์ ์ด๋ฒคํธ์ ๋ํ ์๋ต์ผ๋ก๋ง dispatchAction์ ํธ์ถํ์ธ์.