Skip to content

Latest commit

ย 

History

History
1565 lines (1233 loc) ยท 44.2 KB

File metadata and controls

1565 lines (1233 loc) ยท 44.2 KB
title useActionState

useActionState๋Š” Action์„ ์‚ฌ์šฉํ•ด ์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ๋ฅผ ๋™๋ฐ˜ํ•œ ์ƒํƒœ ์—…๋ฐ์ดํŠธ๋ฅผ ํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” React Hook์ž…๋‹ˆ๋‹ค.

const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState, permalink?);

๋ ˆํผ๋Ÿฐ์Šค {/reference/}

useActionState(reducerAction, initialState, permalink?) {/useactionstate/}

์ปดํฌ๋„ŒํŠธ์˜ ์ตœ์ƒ์œ„ ๋ ˆ๋ฒจ์—์„œ useActionState๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ Action์˜ ๊ฒฐ๊ณผ์— ๋Œ€ํ•œ ์ƒํƒœ๋ฅผ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค.

import { useActionState } from 'react';

function reducerAction(previousState, actionPayload) {
  // ...
}

function MyCart({initialState}) {
  const [state, dispatchAction, isPending] = useActionState(reducerAction, initialState);
  // ...
}

์•„๋ž˜์—์„œ ๋” ๋งŽ์€ ์˜ˆ์‹œ๋ฅผ ํ™•์ธํ•˜์„ธ์š”.

๋งค๊ฐœ๋ณ€์ˆ˜ {/parameters/}

  • reducerAction: Action์ด ํŠธ๋ฆฌ๊ฑฐ๋  ๋•Œ ํ˜ธ์ถœ๋  ํ•จ์ˆ˜์ž…๋‹ˆ๋‹ค. ํ˜ธ์ถœ๋  ๋•Œ ์ฒซ ๋ฒˆ์งธ ์ธ์ˆ˜๋กœ ์ด์ „ ์ƒํƒœ(์ดˆ๊ธฐ์—๋Š” ์ œ๊ณตํ•œ initialState, ์ดํ›„์—๋Š” ์ด์ „ ๋ฐ˜ํ™˜๊ฐ’)๋ฅผ ๋ฐ›๊ณ , ๋‘ ๋ฒˆ์งธ ์ธ์ˆ˜๋กœ dispatchAction์— ์ „๋‹ฌ๋œ actionPayload๋ฅผ ๋ฐ›์Šต๋‹ˆ๋‹ค.
  • initialState: ์ƒํƒœ์˜ ์ดˆ๊นƒ๊ฐ’์ž…๋‹ˆ๋‹ค. React๋Š” dispatchAction์ด ์ฒ˜์Œ ํ˜ธ์ถœ๋œ ์ดํ›„์—๋Š” ์ด ์ธ์ˆ˜๋ฅผ ๋ฌด์‹œํ•ฉ๋‹ˆ๋‹ค.
  • ์„ ํƒ ์‚ฌํ•ญ permalink: ์ด ํผ์ด ์ˆ˜์ •ํ•˜๋Š” ๊ณ ์œ ํ•œ ํŽ˜์ด์ง€ URL์„ ํฌํ•จํ•˜๋Š” ๋ฌธ์ž์—ด์ž…๋‹ˆ๋‹ค.
    • ์ ์ง„์  ํ–ฅ์ƒ(progressive enhancement)๊ณผ ํ•จ๊ป˜ React Server Components๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ํŽ˜์ด์ง€์—์„œ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•œ ์šฉ๋„์ž…๋‹ˆ๋‹ค.
    • reducerAction์ด Server Function์ด๊ณ  JavaScript ๋ฒˆ๋“ค์ด ๋กœ๋“œ๋˜๊ธฐ ์ „์— ํผ์ด ์ œ์ถœ๋˜๋ฉด, ๋ธŒ๋ผ์šฐ์ €๋Š” ํ˜„์žฌ ํŽ˜์ด์ง€์˜ URL์ด ์•„๋‹Œ ์ง€์ •๋œ permalink URL๋กœ ์ด๋™ํ•ฉ๋‹ˆ๋‹ค.

๋ฐ˜ํ™˜๊ฐ’ {/returns/}

useActionState๋Š” ์ •ํ™•ํžˆ ์„ธ ๊ฐœ์˜ ๊ฐ’์„ ๊ฐ€์ง„ ๋ฐฐ์—ด์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

  1. ํ˜„์žฌ ์ƒํƒœ. ์ฒซ ๋ฒˆ์งธ ๋ Œ๋”๋ง ์ค‘์—๋Š” ์ „๋‹ฌํ•œ initialState์™€ ์ผ์น˜ํ•ฉ๋‹ˆ๋‹ค. dispatchAction์ด ํ˜ธ์ถœ๋œ ํ›„์—๋Š” reducerAction์ด ๋ฐ˜ํ™˜ํ•œ ๊ฐ’๊ณผ ์ผ์น˜ํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.
  2. Actions ๋‚ด๋ถ€์—์„œ ํ˜ธ์ถœํ•˜๋Š” dispatchAction ํ•จ์ˆ˜.
  3. ์ด Hook์— ๋Œ€ํ•ด ๋””์ŠคํŒจ์น˜๋œ Action์ด ๋Œ€๊ธฐ ์ค‘(pending)์ธ์ง€ ์•Œ๋ ค์ฃผ๋Š” isPending ํ”Œ๋ž˜๊ทธ.

์ฃผ์˜ ์‚ฌํ•ญ {/caveats/}

  • 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์˜ ์ผ๋ถ€๋กœ ์ทจ๊ธ‰๋˜์ง€ ์•Š์œผ๋ฉฐ ๊ฐœ๋ฐœ ๋ชจ๋“œ์—์„œ ์—๋Ÿฌ ๋กœ๊ทธ๋ฅผ ๋‚จ๊น๋‹ˆ๋‹ค.


reducerAction ํ•จ์ˆ˜ {/reduceraction/}

useActionState์— ์ „๋‹ฌ๋œ reducerAction ํ•จ์ˆ˜๋Š” ์ด์ „ ์ƒํƒœ๋ฅผ ๋ฐ›๊ณ  ์ƒˆ๋กœ์šด ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

useReducer์˜ ๋ฆฌ๋“€์„œ์™€๋Š” ๋‹ฌ๋ฆฌ reducerAction์€ ๋น„๋™๊ธฐ์ ์ผ ์ˆ˜ ์žˆ์œผ๋ฉฐ ์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ๋ฅผ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

async function reducerAction(previousState, actionPayload) {
  const newState = await post(actionPayload);
  return newState;
}

dispatchAction์„ ํ˜ธ์ถœํ•  ๋•Œ๋งˆ๋‹ค React๋Š” actionPayload์™€ ํ•จ๊ป˜ reducerAction์„ ํ˜ธ์ถœํ•ฉ๋‹ˆ๋‹ค. ๋ฆฌ๋“€์„œ๋Š” ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒŒ์‹œ(posting)ํ•˜๋Š” ๋“ฑ์˜ ์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ๋ฅผ ์ˆ˜ํ–‰ํ•˜๊ณ  ์ƒˆ ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. dispatchAction์ด ์—ฌ๋Ÿฌ ๋ฒˆ ํ˜ธ์ถœ๋˜๋ฉด React๋Š” ์ด๋ฅผ ์ˆœ์„œ๋Œ€๋กœ ํ์— ๋„ฃ๊ณ  ์‹คํ–‰ํ•˜์—ฌ ์ด์ „ ํ˜ธ์ถœ์˜ ๊ฒฐ๊ณผ๊ฐ€ ํ˜„์žฌ ํ˜ธ์ถœ์˜ previousState๋กœ ์ „๋‹ฌ๋˜๋„๋ก ํ•ฉ๋‹ˆ๋‹ค.

๋งค๊ฐœ๋ณ€์ˆ˜ {/reduceraction-parameters/}

  • previousState: ๋งˆ์ง€๋ง‰ ์ƒํƒœ์ž…๋‹ˆ๋‹ค. ์ฒ˜์Œ์—๋Š” initialState์™€ ๋™์ผํ•ฉ๋‹ˆ๋‹ค. dispatchAction์ด ์ฒ˜์Œ ํ˜ธ์ถœ๋œ ์ดํ›„์—๋Š” ๋งˆ์ง€๋ง‰์œผ๋กœ ๋ฐ˜ํ™˜๋œ ์ƒํƒœ์™€ ๊ฐ™์•„์ง‘๋‹ˆ๋‹ค.

  • ์„ ํƒ ์‚ฌํ•ญ actionPayload: dispatchAction์— ์ „๋‹ฌ๋œ ์ธ์ˆ˜์ž…๋‹ˆ๋‹ค. ์–ด๋–ค ํƒ€์ž…์˜ ๊ฐ’์ด๋“  ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. useReducer์˜ ๊ด€๋ก€์™€ ์œ ์‚ฌํ•˜๊ฒŒ ์ผ๋ฐ˜์ ์œผ๋กœ Action์„ ์‹๋ณ„ํ•˜๋Š” type ํ”„๋กœํผํ‹ฐ์™€ ์ถ”๊ฐ€ ์ •๋ณด๊ฐ€ ๋‹ด๊ธด ์„ ํƒ์  ํ”„๋กœํผํ‹ฐ๋ฅผ ๊ฐ€์ง„ ๊ฐ์ฒด์ž…๋‹ˆ๋‹ค.

๋ฐ˜ํ™˜๊ฐ’ {/reduceraction-returns/}

reducerAction์€ ์ƒˆ ์ƒํƒœ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๊ณ  ํ•ด๋‹น ์ƒํƒœ๋กœ ๋ฆฌ๋ Œ๋”๋งํ•˜๊ธฐ ์œ„ํ•œ Transition์„ ํŠธ๋ฆฌ๊ฑฐํ•ฉ๋‹ˆ๋‹ค.

์ฃผ์˜ ์‚ฌํ•ญ {/reduceraction-caveats/}

  • reducerAction์€ ๋™๊ธฐ์ ์ผ ์ˆ˜๋„ ์žˆ๊ณ  ๋น„๋™๊ธฐ์ ์ผ ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค. ์•Œ๋ฆผ์„ ํ‘œ์‹œํ•˜๋Š” ๋“ฑ์˜ ๋™๊ธฐ์  Action์ด๋‚˜ ์„œ๋ฒ„์— ์—…๋ฐ์ดํŠธ๋ฅผ ๊ฒŒ์‹œํ•˜๋Š” ๋“ฑ์˜ ๋น„๋™๊ธฐ์  Action์„ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  • reducerAction์€ ์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ๋ฅผ ํ—ˆ์šฉํ•˜๋„๋ก ์„ค๊ณ„๋˜์—ˆ๊ธฐ ๋•Œ๋ฌธ์— <StrictMode>์—์„œ ๋‘ ๋ฒˆ ํ˜ธ์ถœ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.
  • reducerAction์˜ ๋ฐ˜ํ™˜ ํƒ€์ž…์€ initialState์˜ ํƒ€์ž…๊ณผ ์ผ์น˜ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. TypeScript๊ฐ€ ๋ถˆ์ผ์น˜๋ฅผ ์ถ”๋ก ํ•  ๊ฒฝ์šฐ, ์ƒํƒœ ํƒ€์ž…์„ ๋ช…์‹œ์ ์œผ๋กœ ์ง€์ •ํ•ด์•ผ ํ•  ์ˆ˜๋„ ์žˆ์Šต๋‹ˆ๋‹ค.
  • reducerAction์˜ await ์ดํ›„์— ์ƒํƒœ๋ฅผ ์„ค์ •ํ•˜๋Š” ๊ฒฝ์šฐ, ํ˜„์žฌ๋Š” ์ƒํƒœ ์—…๋ฐ์ดํŠธ๋ฅผ ์ถ”๊ฐ€์ ์ธ startTransition์œผ๋กœ ๊ฐ์‹ธ์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ž์„ธํ•œ ์ •๋ณด๋Š” startTransition ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜์„ธ์š”.
  • Server Functions๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ, actionPayload๋Š” ์ง๋ ฌํ™” ๊ฐ€๋Šฅ(serializable)ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค(์ผ๋ฐ˜ ๊ฐ์ฒด, ๋ฐฐ์—ด, ๋ฌธ์ž์—ด, ์ˆซ์ž์™€ ๊ฐ™์€ ๊ฐ’).

์™œ reducerAction์ด๋ผ๊ณ  ๋ถ€๋ฅด๋‚˜์š”? {/why-is-it-called-reduceraction/}

useActionState์— ์ „๋‹ฌ๋œ ํ•จ์ˆ˜๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์ด์œ ๋กœ reducer action์ด๋ผ๊ณ  ๋ถˆ๋ฆฝ๋‹ˆ๋‹ค.

  • useReducer์™€ ๊ฐ™์ด ์ด์ „ ์ƒํƒœ๋ฅผ ์ƒˆ๋กœ์šด ์ƒํƒœ๋กœ *์ถ•์†Œ(reduce)*ํ•˜๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.
  • Transition ๋‚ด๋ถ€์—์„œ ํ˜ธ์ถœ๋˜๊ณ  ์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ๋ฅผ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ๋Š” Action์ด๊ธฐ ๋•Œ๋ฌธ์ž…๋‹ˆ๋‹ค.

๊ฐœ๋…์ ์œผ๋กœ useActionState๋Š” useReducer์™€ ๊ฐ™์ง€๋งŒ ๋ฆฌ๋“€์„œ ์•ˆ์—์„œ ์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ๋ฅผ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ๋‹ค๋Š” ์ ์ด ๋‹ค๋ฆ…๋‹ˆ๋‹ค.


์‚ฌ์šฉ๋ฒ• {/usage/}

Action์— ์ƒํƒœ ์ถ”๊ฐ€ํ•˜๊ธฐ {/adding-state-to-an-action/}

์ปดํฌ๋„ŒํŠธ์˜ ์ตœ์ƒ์œ„ ๋ ˆ๋ฒจ์—์„œ useActionState๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ Action์˜ ๊ฒฐ๊ณผ์— ๋Œ€ํ•œ ์ƒํƒœ๋ฅผ ์ƒ์„ฑํ•˜์„ธ์š”.

import { useActionState } from 'react';

async function addToCartAction(prevCount) {
  // ...
}
function Counter() {
  const [count, dispatchAction, isPending] = useActionState(addToCartAction, 0);

  // ...
}

useActionState๋Š” ์ •ํ™•ํžˆ ์„ธ ๊ฐœ์˜ ํ•ญ๋ชฉ์ด ํฌํ•จ๋œ ๋ฐฐ์—ด์„ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค.

  1. ์ œ๊ณตํ•œ ์ดˆ๊ธฐ ์ƒํƒœ๋กœ ์„ค์ •๋œ ํ˜„์žฌ ์ƒํƒœ.
  2. reducerAction์„ ํŠธ๋ฆฌ๊ฑฐํ•  ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” Action ๋””์ŠคํŒจ์ฒ˜.
  3. 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๋Š” ๋ชจ๋“  ํ‹ฐ์ผ“์ด ์ถ”๊ฐ€๋  ๋•Œ๊นŒ์ง€ ๋ณด๋ฅ˜ ์ƒํƒœ๋ฅผ ํ‘œ์‹œํ•˜๊ณ , ์ตœ์ข… ์ƒํƒœ๋กœ ๋ฆฌ๋ Œ๋”๋ง์„ ์ง„ํ–‰ํ•ฉ๋‹ˆ๋‹ค.

useActionState ํ์ž‰(Queuing) ๋ฐฉ์‹ {/how-useactionstate-queuing-works/}

"Add Ticket"์„ ์—ฌ๋Ÿฌ ๋ฒˆ ํด๋ฆญํ•ด ๋ณด์„ธ์š”. ํด๋ฆญํ•  ๋•Œ๋งˆ๋‹ค ์ƒˆ๋กœ์šด addToCartAction์ด ํ์— ์Œ“์ž…๋‹ˆ๋‹ค. ์ธ์œ„์ ์ธ 1์ดˆ์˜ ์ง€์—ฐ ์‹œ๊ฐ„์ด ์žˆ์œผ๋ฏ€๋กœ 4๋ฒˆ ํด๋ฆญํ•˜๋ฉด ์™„๋ฃŒํ•˜๋Š” ๋ฐ ์•ฝ 4์ดˆ๊ฐ€ ๊ฑธ๋ฆฝ๋‹ˆ๋‹ค.

์ด๋Š” useActionState ์„ค๊ณ„์ƒ ์˜๋„๋œ ๋™์ž‘์ž…๋‹ˆ๋‹ค.

React๋Š” ๋‹ค์Œ addToCartAction ํ˜ธ์ถœ์— prevCount๋ฅผ ์ „๋‹ฌํ•˜๊ธฐ ์œ„ํ•ด ์ด์ „ addToCartAction์˜ ๊ฒฐ๊ณผ๋ฅผ ๊ธฐ๋‹ค๋ ค์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์ฆ‰, ๋‹ค์Œ Action์„ ํ˜ธ์ถœํ•˜๊ธฐ ์ „์— ์ด์ „ Action์ด ๋๋‚  ๋•Œ๊นŒ์ง€ ๊ธฐ๋‹ค๋ ค์•ผ ํ•ฉ๋‹ˆ๋‹ค.

์ผ๋ฐ˜์ ์œผ๋กœ useOptimistic๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜์—ฌ ์ด ๋ฌธ์ œ๋ฅผ ํ•ด๊ฒฐํ•  ์ˆ˜ ์žˆ์ง€๋งŒ, ๋” ๋ณต์žกํ•œ ๊ฒฝ์šฐ์—๋Š” ๋Œ€๊ธฐ ์ค‘์ธ Action ์ทจ์†Œํ•˜๊ธฐ๋ฅผ ๊ณ ๋ คํ•˜๊ฑฐ๋‚˜ useActionState๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•ด ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


์—ฌ๋Ÿฌ Action ํƒ€์ž… ์‚ฌ์šฉํ•˜๊ธฐ {/using-multiple-action-types/}

์—ฌ๋Ÿฌ ํƒ€์ž…์„ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์œ„ํ•ด 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์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

useActionState๋Š” useReducer์™€ ์–ด๋–ป๊ฒŒ ๋‹ค๋ฅธ๊ฐ€์š”? {/useactionstate-vs-usereducer/}

์ด ์˜ˆ์‹œ๊ฐ€ useReducer์™€ ๋งค์šฐ ๋น„์Šทํ•ด ๋ณด์ผ ์ˆ˜ ์žˆ์ง€๋งŒ ๊ทธ ๋ชฉ์ ์ด ๋‹ค๋ฆ…๋‹ˆ๋‹ค.

  • UI์˜ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๋ ค๋ฉด useReducer๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”. ๋ฆฌ๋“€์„œ๋Š” ์ˆœ์ˆ˜(pure)ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

  • Action์˜ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๋ ค๋ฉด useActionState๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”. ๋ฆฌ๋“€์„œ๋Š” ์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ๋ฅผ ์ˆ˜ํ–‰ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

useActionState๋Š” ์‚ฌ์šฉ์ž Action์œผ๋กœ ์ธํ•œ ์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ๋ฅผ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์œ„ํ•œ useReducer๋ผ๊ณ  ์ƒ๊ฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์ด์ „ Action์„ ๊ธฐ๋ฐ˜์œผ๋กœ ๋‹ค์Œ ์‹คํ–‰ํ•  Action์„ ๊ณ„์‚ฐํ•˜๊ธฐ ๋•Œ๋ฌธ์— ํ˜ธ์ถœ์„ ์ฐจ๋ก€๋Œ€๋กœ ๋Œ€๊ธฐ์—ด์— ์ถ”๊ฐ€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ์—ฌ๋Ÿฌ Action์„ ๋ณ‘๋ ฌ๋กœ ์ˆ˜ํ–‰ํ•˜๋ ค๋ฉด useState์™€ useTransition์„ ์ง์ ‘ ์‚ฌ์šฉํ•˜์„ธ์š”.


useOptimistic๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๊ธฐ {/using-with-useoptimistic/}

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๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๊ธฐ {/using-with-action-props/}

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) ๋ฐ”๊ฟ€์ง€๋Š” ์ปดํฌ๋„ŒํŠธ๊ฐ€ ๋Œ€์‹  ์ฒ˜๋ฆฌํ•ด ์ค๋‹ˆ๋‹ค.


ํ์— ๋Œ€๊ธฐ ์ค‘์ธ Action ์ทจ์†Œํ•˜๊ธฐ {/cancelling-queued-actions/}

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๊ฐ€ ๊ธฐ๋ณธ์ ์œผ๋กœ ์ค‘๋‹จ ๊ธฐ๋Šฅ์„ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ์ด์œ ์ž…๋‹ˆ๋‹ค. ์‚ฌ์ด๋“œ ์ดํŽ™ํŠธ๋ฅผ ์•ˆ์ „ํ•˜๊ฒŒ ๋ฌด์‹œํ•˜๊ฑฐ๋‚˜ ์žฌ์‹œ๋„ํ•  ์ˆ˜ ์žˆ๋‹ค๊ณ  ํ™•์‹ ํ•  ๋•Œ๋งŒ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.


<form> Action prop๊ณผ ํ•จ๊ป˜ ์‚ฌ์šฉํ•˜๊ธฐ {/use-with-a-form/}

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> ๋ฌธ์„œ๋ฅผ ์ฐธ์กฐํ•˜์„ธ์š”.


์˜ค๋ฅ˜ ์ฒ˜๋ฆฌํ•˜๊ธฐ {/handling-errors/}

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๋ฅผ ํ‘œ์‹œํ•˜๊ฒŒ ๋ฉ๋‹ˆ๋‹ค.


๋ฌธ์ œ ํ•ด๊ฒฐ {/troubleshooting/}

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์œผ๋กœ ๊ฐ์‹ธ์ค๋‹ˆ๋‹ค.


๋‚ด Action์ด ํผ ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค {/action-cannot-read-form-data/}

useActionState๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ, reducerAction์€ ์ฒซ ๋ฒˆ์งธ ์ธ์ˆ˜๋กœ ์ด์ „ ์ƒํƒœ(๋˜๋Š” ์ดˆ๊ธฐ ์ƒํƒœ)๋ผ๋Š” ์ถ”๊ฐ€์ ์ธ ์ธ์ˆ˜๋ฅผ ๋ฐ›๊ฒŒ ๋ฉ๋‹ˆ๋‹ค. ๋”ฐ๋ผ์„œ ์ œ์ถœ๋œ ํผ ๋ฐ์ดํ„ฐ๋Š” ์ฒซ ๋ฒˆ์งธ๊ฐ€ ์•„๋‹ˆ๋ผ ๋‘ ๋ฒˆ์งธ ์ธ์ˆ˜๊ฐ€ ๋ฉ๋‹ˆ๋‹ค.

// useActionState๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š์„ ๋•Œ
function action(formData) {
  const name = formData.get('name');
}

// useActionState๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ
function action(prevState, formData) {
  const name = formData.get('name');
}

๋‚ด Action์ด ๋ฌด์‹œ๋˜๊ณ  ๊ฑด๋„ˆ๋œ๋‹ˆ๋‹ค {/actions-skipped/}

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 };
  }
}

์ƒํƒœ๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค {/reset-state/}

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์„ ํ˜ธ์ถœํ•˜์„ธ์š”.