ข้ามไปยังเนื้อหา

useReducer Patterns

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 คำนวณผลลัพธ์ในที่เดียว

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
setState กระจัดกระจาย เทียบกับ reducer เดียว

ไปหา useReducer แทน useState เมื่อ:

  • การเปลี่ยน state ซับซ้อน — หลาย field เปลี่ยนพร้อมกัน หรือค่าถัดไปขึ้นกับค่าก่อนหน้าแบบไม่ตรงไปตรงมา
  • คุณอยากได้ตรรกะไว้ที่เดียวที่ test ได้ — reducer เป็นแค่ function ธรรมดาที่ unit-test ได้โดยไม่ต้องมี React เลย: ใส่ reducer(state, action) ได้ nextState ออกมา
  • Action บันทึกเจตนา — dispatch({ type: "checkout_failed" }) อ่านเข้าใจง่ายกว่ากองของ setter และให้ log ว่า เกิดอะไรขึ้น

useReducer มีข้อตกลงเรื่อง purity เหมือน updater form ของ useState เป๊ะ และ Strict Mode บังคับมันแบบเดียวกัน: ตอน dev มันเรียก reducer ของคุณ สองครั้ง ต่อการ dispatch หนึ่งครั้ง (ด้วย state และ action เดิม) เพื่อช่วยเผย reducer ที่มี side effect — แต่ commit ผลลัพธ์แค่ครั้งเดียว

src/components/ReducerStrict.tsx
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>
);
}
src/__tests__/reducer-strict.test.tsx
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 ในที่เดิมข้างในก็ตาม

src/components/ImpureReducer.tsx
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>
);
}
src/__tests__/impure-reducer.test.tsx
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 ดูเหมือนไม่ทำอะไรเลยได้

เหมือน useState, useReducer รับ argument ตัวที่สาม: initializer function ที่ถูกเรียกแค่ครั้งเดียว โดยรับ argument ตัวที่สองเป็น input ใช้มันเมื่อการคำนวณ initial state มีต้นทุนสูง

src/components/LazyInitReducer.tsx
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>;
}
src/__tests__/lazy-init-reducer.test.tsx
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

การกำหนด 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 จะหายไป:

src/components/ExhaustiveReducer.tsx
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 แบบเงียบ ๆ

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 มาให้ฟรี ๆ

รวมสองเครื่องมือจากโมดูลนี้เข้าด้วยกัน คุณจะได้ 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

src/components/TasksContext.tsx
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>;
}
src/__tests__/tasks-context.test.tsx
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 เลย

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 checktsc จับ 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 เลย

ก่อนเชื่อ reducer ที่ generate มาหรือการ wiring ของมัน ให้เช็กสิ่งเหล่านี้:

  • reducer mutate state (.push, .sort, assign property ตรง ๆ) แล้ว return state ไหม? นั่นไม่ใช่แค่ style — มันทำให้ React ข้าม re-render ไปเลยได้ ตามที่พิสูจน์แล้วข้างบน
  • Action ถูกกำหนด type แบบหลวม ๆ ({ type: string; payload?: any }) แทนที่จะเป็น discriminated union ไหม? ถ้าไม่มี literal type ที่ไม่ซ้ำกัน 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 ได้อย่างน่าเชื่อถือ

reducer คืออะไร?
เมื่อไรควรเลือก useReducer แทน useState?
ใน pattern context + reducer ทำไมถึง split state กับ dispatch ไว้คนละ context?
reducer ห้ามทำอะไร?
ตอน dev เมื่อคุณ dispatch action ภายใต้ StrictMode เกิดอะไรขึ้น?
ถ้า Action มี { type: 'reset'; to: number } ด้วย และ case นี้ไม่เคยถูก handle เลย tsc --strict จะรายงานว่าอย่างไร?
function reducer(state, action: Action) {
  switch (action.type) {
    case 'increment': return { count: state.count + 1 };
    case 'decrement': return { count: state.count - 1 };
    default: {
      const exhaustive: never = action;
      return exhaustive;
    }
  }
}
ผลที่สังเกตได้จากการ dispatch add_item ซ้ำ ๆ คืออะไร?
function cartReducer(state, action) {
  switch (action.type) {
    case "add_item":
      state.items.push(action.item);
      return state;
    default:
      return state;
  }
}