useReducer Patterns
จากหลาย setState สู่ reducer เดียว
หัวข้อที่มีชื่อว่า “จากหลาย setState สู่ reducer เดียว”useState เหมาะมากสำหรับ state ที่เป็นอิสระจากกัน แต่เมื่อหลายค่าต้องเปลี่ยน พร้อมกัน ตอบสนองต่อ event เดียวกัน — และค่าถัดไปขึ้นกับ state ปัจจุบันกับ action — reducer จะรวมตรรกะนั้นไว้ใน pure function เดียว
// The reducer is a pure function: (state, action) => nextState.function tasksReducer(state, action) { switch (action.type) { case "added": return [...state, { id: action.id, text: action.text, done: false }]; case "toggled": return state.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t)); case "deleted": return state.filter((t) => t.id !== action.id); default: throw new Error("unknown action: " + action.type); }}
function TaskApp() { const [tasks, dispatch] = useReducer(tasksReducer, []); // You describe WHAT happened (an action); the reducer decides the next state. return <button onClick={() => dispatch({ type: "added", id: 1, text: "Learn" })}>Add</button>;}useReducer คืน state ปัจจุบันกับ function dispatch คุณไม่ได้คำนวณ state ถัดไปตรงจุดที่เรียก — คุณ dispatch action ที่บอกว่าเกิดอะไรขึ้น แล้ว reducer คำนวณผลลัพธ์ในที่เดียว
ทำไมควรเลือก reducer
หัวข้อที่มีชื่อว่า “ทำไมควรเลือก reducer”flowchart TB
subgraph a["setState หลายตัว"]
e1["event"] --> s1["setX"]
e1 --> s2["setY"]
e1 --> s3["setZ (ตรรกะกระจัดกระจาย)"]
end
subgraph b["reducer เดียว"]
e2["event"] --> d["dispatch(action)"] --> r["reducer: ตรรกะการเปลี่ยนทั้งหมดอยู่ที่นี่"]
end ไปหา useReducer แทน useState เมื่อ:
- การเปลี่ยน state ซับซ้อน — หลาย field เปลี่ยนพร้อมกัน หรือค่าถัดไปขึ้นกับค่าก่อนหน้าแบบไม่ตรงไปตรงมา
- คุณอยากได้ตรรกะไว้ที่เดียวที่ test ได้ — reducer เป็นแค่ function ธรรมดาที่ unit-test ได้โดยไม่ต้องมี React เลย: ใส่
reducer(state, action)ได้nextStateออกมา - Action บันทึกเจตนา —
dispatch({ type: "checkout_failed" })อ่านเข้าใจง่ายกว่ากองของ setter และให้ log ว่า เกิดอะไรขึ้น
พิสูจน์: reducer ต้อง pure — StrictMode เรียกมันสองครั้ง
หัวข้อที่มีชื่อว่า “พิสูจน์: reducer ต้อง pure — StrictMode เรียกมันสองครั้ง”useReducer มีข้อตกลงเรื่อง purity เหมือน updater form ของ useState เป๊ะ และ Strict Mode บังคับมันแบบเดียวกัน: ตอน dev มันเรียก reducer ของคุณ สองครั้ง ต่อการ dispatch หนึ่งครั้ง (ด้วย state และ action เดิม) เพื่อช่วยเผย reducer ที่มี side effect — แต่ commit ผลลัพธ์แค่ครั้งเดียว
import { StrictMode, useReducer } from 'react';
type Action = { type: 'inc' };
export function makeReducer(log: number[]) { return function reducer(state: number, action: Action) { log.push(state); switch (action.type) { case 'inc': return state + 1; default: return state; } };}
export function Counter({ log }: { log: number[] }) { const [count, dispatch] = useReducer(makeReducer(log), 0); return <button onClick={() => dispatch({ type: 'inc' })}>{count}</button>;}
export function StrictCounter({ log }: { log: number[] }) { return ( <StrictMode> <Counter log={log} /> </StrictMode> );}import { render, screen, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { Counter, StrictCounter } from '../components/ReducerStrict';
afterEach(cleanup);
test('without StrictMode, the reducer runs once per dispatch', () => { const log: number[] = []; render(<Counter log={log} />); act(() => { screen.getByRole('button').click(); }); expect(log).toEqual([0]); expect(screen.getByRole('button').textContent).toBe('1');});
test('StrictMode calls the reducer twice with the same state, but commits once', () => { const log: number[] = []; render(<StrictCounter log={log} />); act(() => { screen.getByRole('button').click(); }); // Called twice with the SAME starting state (0, 0) — not (0, 1) — proving // it's a double-invoke for purity-checking, not a real double-dispatch. expect(log).toEqual([0, 0]); expect(screen.getByRole('button').textContent).toBe('1');});✓ without StrictMode, the reducer runs once per dispatch✓ StrictMode calls the reducer twice with the same state, but commits once
Test Files 1 passed (1) Tests 2 passed (2)Reducer purity เป็นกฎเดียวกับ render: ใส่ state กับ action เดิม คืนค่า state ถัดไปเดิมเสมอ ไม่มี side effect และ ไม่ mutate (คืน object/array ใหม่) log พิสูจน์ว่า ทำไม ถึงสำคัญ — ทั้งสอง call ของ StrictMode เห็น state เริ่มต้นเดียวกันคือ 0 ดังนั้น side effect ใด ๆ ใน reducer (console.log, เรียก analytics, push เข้า array จาก scope ข้างนอก) จะยิงสองครั้งสำหรับ dispatch เดียว
พิสูจน์: mutate state ใน reducer ไม่ได้แค่เสี่ยงข้อมูลซ้ำ — มันหยุด render ได้แบบเงียบ ๆ
หัวข้อที่มีชื่อว่า “พิสูจน์: mutate state ใน reducer ไม่ได้แค่เสี่ยงข้อมูลซ้ำ — มันหยุด render ได้แบบเงียบ ๆ”เอกสารอ้างอิง useReducer โชว์ความผิดพลาดแบบนี้ตรง ๆ และเรียกกลไกตรง ๆ ว่า “Mutating state directly causes React to ignore the update because Object.is comparison determines that the next state is equal to the previous state”:
function reducer(state, action) { switch (action.type) { case 'added_todo': { // 🚩 Mistake: mutating state state.todos.push({ id: nextId++, text: action.text }); return state; } // ... }}นี่แย่กว่าที่ดูตอนแรก เพราะ reducer คืน reference ของ object ระดับบนสุดตัวเดิมเป๊ะ bail-out check ของ React (Object.is(prevState, nextState)) จึงสรุปว่า “ไม่มีอะไรเปลี่ยน” — มันเลยข้าม re-render ไปได้เลย แม้ array จะถูก mutate ในที่เดิมข้างในก็ตาม
import { StrictMode, useReducer } from 'react';
type Todo = { id: number; text: string };type State = { todos: Todo[] };type Action = { type: 'added_todo'; text: string };
let nextId = 0;
// ❌ Impure: mutates state.todos directly instead of returning a new array.function impureReducer(state: State, action: Action): State { switch (action.type) { case 'added_todo': { state.todos.push({ id: nextId++, text: action.text }); return state; } default: return state; }}
// ✅ Pure: returns a new state object/array every time.function pureReducer(state: State, action: Action): State { switch (action.type) { case 'added_todo': { return { todos: [...state.todos, { id: nextId++, text: action.text }] }; } default: return state; }}
export function StrictImpureApp() { const [state, dispatch] = useReducer(impureReducer, { todos: [] }); return ( <StrictMode> <button onClick={() => dispatch({ type: 'added_todo', text: 'a' })}>add</button> <span data-testid="count">{state.todos.length}</span> </StrictMode> );}
export function StrictPureApp() { const [state, dispatch] = useReducer(pureReducer, { todos: [] }); return ( <StrictMode> <button onClick={() => dispatch({ type: 'added_todo', text: 'a' })}>add</button> <span data-testid="count">{state.todos.length}</span> </StrictMode> );}import { render, screen, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { StrictImpureApp, StrictPureApp } from '../components/ImpureReducer';
afterEach(cleanup);
test('the impure, mutating reducer never re-renders — the count stays stuck', () => { render(<StrictImpureApp />); act(() => { screen.getByRole('button').click(); }); // Same top-level state reference returned every time → React bails out of // the re-render entirely, so the DOM never reflects the mutation. expect(screen.getByTestId('count').textContent).toBe('0');});
test('the pure reducer correctly re-renders once per dispatch, even under StrictMode', () => { render(<StrictPureApp />); act(() => { screen.getByRole('button').click(); }); expect(screen.getByTestId('count').textContent).toBe('1');});✓ the impure, mutating reducer never re-renders — the count stays stuck✓ the pure reducer correctly re-renders once per dispatch, even under StrictMode
Test Files 1 passed (1) Tests 2 passed (2)การ mutate ไม่ได้แค่เสี่ยงต่อความผิดพลาดที่มองเห็นได้ — เพราะ Object.is เห็น reference เดิมกลับมา React จึงข้าม update ไปเลยแบบเงียบ ๆ และตัวเลขที่ปุ่มโชว์ไม่ขยับไปจาก 0 เลยไม่ว่าจะคลิกกี่ครั้งก็ตาม purity ของ reducer ไม่ใช่แค่เรื่อง style ในที่นี้: การ mutate แล้วคืน reference เดิมทำให้ dispatch ดูเหมือนไม่ทำอะไรเลยได้
Lazy initialization สำหรับ useReducer
หัวข้อที่มีชื่อว่า “Lazy initialization สำหรับ useReducer”เหมือน useState, useReducer รับ argument ตัวที่สาม: initializer function ที่ถูกเรียกแค่ครั้งเดียว โดยรับ argument ตัวที่สองเป็น input ใช้มันเมื่อการคำนวณ initial state มีต้นทุนสูง
import { useReducer } from 'react';
type State = { count: number };type Action = { type: 'inc' };
function reducer(state: State, action: Action): State { switch (action.type) { case 'inc': return { count: state.count + 1 }; default: return state; }}
export function createInitialState(seed: number, calls: number[]): State { calls.push(seed); // records every time this actually runs return { count: seed };}
export function LazyInitCounter({ seed, calls }: { seed: number; calls: number[] }) { const [state, dispatch] = useReducer(reducer, seed, (s) => createInitialState(s, calls)); return <button onClick={() => dispatch({ type: 'inc' })}>{state.count}</button>;}import { render, screen, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { LazyInitCounter } from '../components/LazyInitReducer';
afterEach(cleanup);
test('the initializer function runs exactly once, no matter how many dispatches follow', () => { const calls: number[] = []; render(<LazyInitCounter seed={10} calls={calls} />); act(() => { screen.getByRole('button').click(); }); act(() => { screen.getByRole('button').click(); }); expect(calls).toEqual([10]); expect(screen.getByRole('button').textContent).toBe('12');});✓ the initializer function runs exactly once, no matter how many dispatches follow
Test Files 1 passed (1) Tests 1 passed (1)useReducer(reducer, seed, createInitialState) เรียก createInitialState(seed) แค่ครั้งเดียวตอน render แรก แล้วเมินมันหลังจากนั้น — เหมือน useState(() => expensive()) เป๊ะ แค่ argument เริ่มต้นถูกส่งผ่านเข้ามาแทนที่จะ close over
Discriminated-union action: ให้ tsc จับ case ที่หายไป
หัวข้อที่มีชื่อว่า “Discriminated-union action: ให้ tsc จับ case ที่หายไป”การกำหนด type ของ Action เป็น discriminated union (แต่ละ variant มี field type ที่เป็น literal ไม่ซ้ำกัน) ทำให้ TypeScript narrow action ข้างใน case แต่ละอันได้ และให้คุณเพิ่ม exhaustiveness check ใน default ที่ compile ไม่ผ่านถ้ามี case ไหนถูกลืม:
type Action = | { type: 'increment' } | { type: 'decrement' } | { type: 'reset'; to: number };
function reducer(state: { count: number }, action: Action) { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; default: { // If every case above were handled, `action` would be narrowed to // `never` here. Leaving out `reset` makes this assignment fail. const exhaustive: never = action; return exhaustive; } }}error TS2322: Type '{ type: "reset"; to: number; }' is not assignable to type 'never'.นี่คือ output จริงของ tsc --strict กับ snippet ข้างบนที่ลืมใส่ case 'reset' — ดึงมาจากโพรบตรง ๆ ไม่ได้ paraphrase เพิ่ม case ที่หายไปแล้ว error จะหายไป:
type Action = | { type: 'increment' } | { type: 'decrement' } | { type: 'reset'; to: number };
export function reducer(state: { count: number }, action: Action): { count: number } { switch (action.type) { case 'increment': return { count: state.count + 1 }; case 'decrement': return { count: state.count - 1 }; case 'reset': return { count: action.to }; default: { const exhaustive: never = action; return exhaustive; } }}check แบบนี้เสียแค่บรรทัดเดียว แต่จับความผิดพลาดจริงที่พบบ่อย — เพิ่ม action variant ตัวที่สี่อีกหกเดือนข้างหน้า แล้วลืม handle มันในทุก reducer ที่ switch บนมัน tsc จะปฏิเสธ build แทนที่จะปล่อยให้ตกไปที่ default แบบเงียบ ๆ
useReducer เทียบกับ useActionState
หัวข้อที่มีชื่อว่า “useReducer เทียบกับ useActionState”useActionState ของ React 19 ดูคล้ายกันผิวเผิน — มันคืน state บวก function คล้าย dispatch ที่ขับเคลื่อนด้วย function ที่มีรูปร่างแบบ reducer เหมือนกัน ความต่างอยู่ที่อะไรเป็นตัว trigger update: dispatch ของ useReducer เป็น synchronous และขับเคลื่อนด้วย event ที่คุณเรียกมันจาก; action function ของ useActionState ถูกสร้างมาสำหรับ transition แบบ async ที่รู้จัก pending — มันติดตาม boolean pending และเชื่อมกับ form action กับ useFormStatus ให้อัตโนมัติ ไปหา useReducer สำหรับการเปลี่ยน state แบบ synchronous ที่อยู่ใน memory (list, local validation state ของฟอร์มหลาย field) ไปหา useActionState เมื่อ “action” เป็น asynchronous โดยธรรมชาติ (server request) และคุณอยาก pending/error state มาให้ฟรี ๆ
Context + reducer: app state โดยไม่ต้องพึ่ง library
หัวข้อที่มีชื่อว่า “Context + reducer: app state โดยไม่ต้องพึ่ง library”รวมสองเครื่องมือจากโมดูลนี้เข้าด้วยกัน คุณจะได้ app-wide state management แบบเบา ๆ ไม่พึ่ง dependency เลย: reducer ถือตรรกะ และ Context ส่ง state กับ dispatch ลงไปตาม tree
const TasksContext = createContext(null);const TasksDispatchContext = createContext(null); // split: state vs dispatch
function TasksProvider({ children }) { const [tasks, dispatch] = useReducer(tasksReducer, []); return ( <TasksContext value={tasks}> <TasksDispatchContext value={dispatch}>{children}</TasksDispatchContext> </TasksContext> );}
// Anywhere below: read state and dispatch actions.function useTasks() { return useContext(TasksContext); }function useTasksDispatch() { return useContext(TasksDispatchContext); }พิสูจน์: component ที่ dispatch อย่างเดียวไม่ re-render เลยแม้ list จะเปลี่ยน
หัวข้อที่มีชื่อว่า “พิสูจน์: component ที่ dispatch อย่างเดียวไม่ re-render เลยแม้ list จะเปลี่ยน”dispatch จาก useReducer มี reference stable ตลอดอายุของ component — React การันตีว่ามันไม่เปลี่ยนข้าม render รวมกับการ split state กับ dispatch ไว้คนละ context แล้ว component ที่อ่านแค่ dispatch (ไม่เคยอ่าน state) จึงไม่มีเหตุผลที่จะ re-render เลยเมื่อ state update
import { createContext, useContext, useReducer, memo, type Dispatch, type ReactNode } from 'react';
type Task = { id: number; text: string; done: boolean };type Action = | { type: 'added'; id: number; text: string } | { type: 'toggled'; id: number };
function tasksReducer(state: Task[], action: Action): Task[] { switch (action.type) { case 'added': return [...state, { id: action.id, text: action.text, done: false }]; case 'toggled': return state.map((t) => (t.id === action.id ? { ...t, done: !t.done } : t)); default: return state; }}
const TasksContext = createContext<Task[]>([]);const TasksDispatchContext = createContext<Dispatch<Action> | null>(null);
export function TasksProvider({ children }: { children: ReactNode }) { const [tasks, dispatch] = useReducer(tasksReducer, []); return ( <TasksContext value={tasks}> <TasksDispatchContext value={dispatch}>{children}</TasksDispatchContext> </TasksContext> );}
export const AddTaskButton = memo(function AddTaskButton({ onRender }: { onRender: () => void }) { const dispatch = useContext(TasksDispatchContext)!; onRender(); return ( <button onClick={() => dispatch({ type: 'added', id: Date.now(), text: 'x' })}>add</button> );});
export function TaskList() { const tasks = useContext(TasksContext); return <span data-testid="count">{tasks.length}</span>;}import { render, screen, act, cleanup } from '@testing-library/react';import { afterEach, expect, test } from 'vitest';import { TasksProvider, AddTaskButton, TaskList } from '../components/TasksContext';
afterEach(cleanup);
test('a dispatch-only component does not re-render when the task list changes', () => { let renders = 0; render( <TasksProvider> <AddTaskButton onRender={() => renders++} /> <TaskList /> </TasksProvider>, ); const baseline = renders;
act(() => { screen.getByRole('button').click(); }); act(() => { screen.getByRole('button').click(); });
// Two dispatches added two tasks — but the dispatch-only button re-rendered // zero additional times, because `dispatch` never changes reference and it // never reads TasksContext (the state). expect(renders - baseline).toBe(0); expect(screen.getByTestId('count').textContent).toBe('2');});✓ a dispatch-only component does not re-render when the task list changes
Test Files 1 passed (1) Tests 1 passed (1)การ split state กับ dispatch ไว้คนละ context หมายความว่า component ที่ dispatch อย่างเดียว (ไม่เคยอ่าน list) จะไม่ re-render เมื่อ list เปลี่ยน — พิสูจน์แล้วข้างบน: dispatch สองครั้ง ปุ่มไม่ re-render เพิ่มเลย ในขณะที่ list เองโตขึ้นเป็น 2 ถูกต้อง pattern นี้ครอบคลุมกรณี “ต้องใช้ Redux” ได้เยอะมากโดยไม่ต้องพึ่ง library เลย
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”reducer เปลี่ยน “อะไรเปลี่ยนไป” ให้กลายเป็น การเรียก pure function เหมือน render เอง: input เดิม (state, action) เข้าไป output เดิม (state ถัดไป) ออกมา ทุกครั้ง โดยไม่มีอะไรสังเกตเห็นได้เกิดขึ้นข้าง ๆ นั่นคือเหตุผลที่ Strict Mode เรียกมันสองครั้งได้อย่างปลอดภัยเพื่อเช็กงานของคุณ — call ที่สองของ pure function แยกไม่ออกจาก call แรก ยกเว้นสำหรับผู้สังเกตที่ยืนอยู่ในที่ที่ไม่ควรอยู่ (console.log, การ mutate input) ถ้าการเรียกซ้ำสองครั้งเปลี่ยนผลลัพธ์ ที่มองเห็นได้ แปลว่า function นั้นไม่ pure ตั้งแต่แรก — การ mutate แล้วคืน reference เดิมคือตัวอย่างที่คมที่สุด: มันไม่ได้แค่ตกการทดสอบ purity แต่ยังทำลายกลไกตรวจจับการเปลี่ยนแปลงของ React เองด้วย
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ประโยชน์ | ต้นทุน |
|---|---|---|
useReducer แทน useState หลายตัว | ตรรกะการเปลี่ยนรวมไว้ที่เดียว test ได้โดยไม่ต้องมี React | พิธีการเยอะขึ้นสำหรับ field ที่เป็นอิสระจากกันจริง ๆ |
| Discriminated-union action + exhaustiveness check | tsc จับ case ที่ลืมได้ตอน compile | ต้องเขียน default branch เพิ่มให้ถูกต้อง |
Lazy init (useReducer(reducer, arg, init)) | setup ที่แพงรันแค่ครั้งเดียวเป๊ะ | ต้อง structure function เพิ่มให้ถูก |
| Context + reducer (split state/dispatch) | app state แบบไม่พึ่ง dependency, component ที่ dispatch อย่างเดียวไม่ re-render เลย | wiring เยอะกว่า useState ตัวเดียว; ยังไม่มี selector (ดูบท Context) |
ใช้ useActionState แทน | ติดตาม pending/error ให้ฟรีสำหรับ action แบบ async | เครื่องมือผิดสำหรับการเปลี่ยนแบบ synchronous ล้วน ๆ ใน memory |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “Strict Mode เรียก reducer สองครั้งแปลว่า action ทำงานสองครั้ง” มันหมายถึงคู่
(state, action)เดียวกันถูกประเมินสองครั้งเพื่อเช็ก purity มีแค่ผลลัพธ์เดียวที่ commit — พิสูจน์แล้วข้างบนด้วย state เริ่มต้นเดียวกันที่ log สองครั้ง ไม่ใช่0แล้ว1 - “การ mutate แล้วคืน
stateจาก reducer เป็นแค่กลิ่นของ style” มันปิดการ re-render ไปเลยแบบเงียบ ๆ ได้: bail-out check ของ React เห็น reference เดิมแล้วสรุปว่าไม่มีอะไรเปลี่ยน UI เลยค้างอยู่ที่จุดเริ่มต้นได้ — พิสูจน์แล้วข้างบน - “
default: throw new Error(...)เหมือนกับ exhaustiveness check” มันจับ action ที่ไม่ถูก handle ตอน runtime หลัง ship ไปแล้ว ส่วนconst exhaustive: never = actionจับ case ที่หายไป ตอน compile ก่อน ship — ทั้งคู่ควรมีไว้ แต่จับความผิดพลาดคนละจังหวะเวลากัน - “
useActionStateก็แค่useReducerเปลี่ยนชื่อ” มันถูกสร้างมาเฉพาะสำหรับ action แบบ async ที่ติดตาม pending (การส่งฟอร์ม, server request) —useReducerไม่มีแนวคิดเรื่องpendingเลย
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”ก่อนเชื่อ reducer ที่ generate มาหรือการ wiring ของมัน ให้เช็กสิ่งเหล่านี้:
- reducer mutate
state(.push,.sort, assign property ตรง ๆ) แล้วreturn stateไหม? นั่นไม่ใช่แค่ style — มันทำให้ React ข้าม re-render ไปเลยได้ ตามที่พิสูจน์แล้วข้างบน Actionถูกกำหนด type แบบหลวม ๆ ({ type: string; payload?: any }) แทนที่จะเป็น discriminated union ไหม? ถ้าไม่มี literaltypeที่ไม่ซ้ำกันtscจะ narrow ข้างในcaseแต่ละอันไม่ได้ และ exhaustiveness check ในdefaultก็ไม่มีความหมายswitchของทุก reducer จบด้วยdefaultจริงที่ throw หรือ exhaustiveness check (const _exhaustive: never = action) ไหม? การปล่อยให้ตกไปที่return stateเงียบ ๆ เมื่อเจอ action type ที่ไม่รู้จักซ่อน typo ในชื่อ action ที่ dispatch ไว้useActionStateถูกใช้กับการเปลี่ยน state แบบ synchronous ล้วน ๆ ในเครื่อง หรือuseReducerถูกใช้ห่อสิ่งที่จริง ๆ เป็น server request แบบ async (ประดิษฐ์ pending/error tracking เองด้วยมือ) ไหม? เลือก hook ให้ตรงกับว่า transition เป็น sync หรือ async- ใน setup แบบ context + reducer, state กับ dispatch ถูกส่งผ่าน context เดียวกัน ไหม? นั่นทำลายจุดประสงค์ทั้งหมด — component ที่ dispatch อย่างเดียวจะยัง re-render ทุกครั้งที่ state เปลี่ยน เพราะมันยัง subscribe context ที่ถือ state อยู่ด้วย
🐛 หา bug ในโค้ดนี้
function cartReducer(state, action) {switch (action.type) { case "add_item": state.items.push(action.item); state.total += action.item.price; return state; default: return state;}}
function CartApp() {const [cart, dispatch] = useReducer(cartReducer, { items: [], total: 0 });return ( <button onClick={() => dispatch({ type: "add_item", item: { price: 9 } })}> Add — total: {cart.total} </button>);}เฉลย
cartReducer mutate state.items และ state.total ตรง ๆ แล้วคืน reference ของ state ตัวเดิมเป๊ะ bail-out check ของ React (Object.is) เห็นว่า reference ไม่เปลี่ยนเลยข้าม re-render ไปได้เลย — พิสูจน์แล้วข้างบนด้วย pattern เดียวกันเป๊ะ (ImpureReducer): total ที่โชว์อาจค้างอยู่ที่ค่าเริ่มต้น แม้ item จะถูก push เข้า array ข้างใต้จริง ๆ ก็ตาม ทางแก้คือคืน object ใหม่ทุกครั้ง: return { items: [...state.items, action.item], total: state.total + action.item.price }; — ตอนนี้ทุก dispatch สร้าง reference ใหม่จริง ๆ และ React re-render ได้อย่างน่าเชื่อถือ