Skip to content

บทที่ 7 — State Management (Zustand, Jotai, Redux Toolkit) ​

← บทที่ 6 | สารบัญ | บทที่ 8 →

🟡 ระดับ: กลาง

📖 คำศัพท์รวมของบท (อ่านก่อน — บทใช้คำเหล่านี้ตลอด):

  • state management = การจัดการ state ที่หลาย component ใช้ร่วมกัน
  • server state = ข้อมูลที่มาจาก API/DB (เราแค่ cache) → ใช้ TanStack Query (บท 4)
  • client state = ข้อมูลที่เกิดใน browser (form input, modal เปิด, theme) → ใช้ Zustand/Context
  • store = "ที่เก็บกลาง" ของ state — component ทุกตัวเข้าถึงได้
  • selector = function เล็กๆ บอกว่า "ฉันสนใจ field ไหนของ store" → component re-render เฉพาะตอนค่านั้นเปลี่ยน
  • atom (Jotai) = ชิ้น state เล็กๆ เก็บแยกกัน (เหมือน useState แต่อยู่นอก component — share ได้)
  • slice (Redux) = กลุ่มของ state + reducers ของ feature เดียว
  • action = object บอกว่า "เกิดอะไรขึ้น" เช่น { type: 'increment' }
  • reducer = function รับ state เก่า + action → คืน state ใหม่
  • dispatch = ส่ง action ไปให้ reducer ทำงาน
  • middleware = ตัวห่อ store เพิ่มความสามารถ (persist, log, devtools)
  • flux = pattern จัดการ state ทางเดียว (action → store → view) ที่ Redux ใช้
  • boilerplate = โค้ดซ้ำ ๆ ที่ต้องเขียนทุกครั้งเพื่อให้ระบบทำงานได้ แม้จะไม่มี logic จริง ๆ (เช่น Redux เก่าที่ต้องสร้าง action types, action creators, reducers แยกไฟล์ทุก feature)
  • immutable = ไม่แก้ object เดิม แต่สร้าง object ใหม่ขึ้นมา — React ตรวจการเปลี่ยนแปลงโดยเทียบ reference ดังนั้นต้องสร้าง object ใหม่เสมอเมื่อ state เปลี่ยน (immer ทำให้เราเขียนเหมือน mutation แต่ข้างในสร้าง object ใหม่ให้อัตโนมัติ)

📖 JS primer ที่จำเป็น — สำคัญสำหรับเข้าใจ §6 Selector:

  • primitive = ค่าพื้นฐาน (number, string, boolean) → JS เทียบด้วย "ค่า" → 1 === 1 คือ true
  • reference (object, array, function) → JS เทียบด้วย "ตำแหน่งใน memory" → {a:1} === {a:1} คือ false (เพราะคนละ object คนละที่อยู่)
  • shallow compare = เทียบเฉพาะระดับบนสุดของ object — ถ้าทุก key/value Object.is กันก็ถือว่าเท่า
  • Object.is = function เทียบค่า เกือบเหมือน === (ยกเว้น NaN กับ -0/+0) — เช่น Object.is(NaN, NaN) = true (แต่ NaN === NaN = false)

บทที่ 2 เรียน useContext แล้ว — พอสำหรับ state เล็ก ๆ
(ถ้ายังไม่รู้จัก useContext ให้อ่านบทที่ 2 ก่อน → บทที่ 2)
บทนี้เรียน library ที่ทำ state ขนาดใหญ่ — Zustand, Jotai, Redux Toolkit

หลังจบบท คุณจะ:

  • เข้าใจว่า "ต้องใช้ state management ตอนไหน" และ "ตอนไหนไม่ต้อง"
  • ใช้ Zustand (มาตรฐานปี 2026)
  • เข้าใจ Jotai (atom-based — แบ่ง state เป็นชิ้นเล็ก ๆ เรียก atom; ดู §10) + Redux Toolkit (boilerplate-less Redux — Redux ที่ตัด boilerplate = โค้ดซ้ำ ๆ ออกแล้ว; ดู §14)
  • รู้จัก server state vs client state — ใช้ TanStack Query vs Zustand ยังไง

1. ทำไมต้องมี State Management Library? ​

useState + useContext ก็ดีอยู่แล้ว — ทำไมต้องมีอื่น?

ปัญหาที่เจอเมื่อ app โตขึ้น ​

1. State ลึกลงไปหลายชั้น (prop drilling)

tsx
<App>
    <Layout user={user} setUser={setUser}>
        <Header user={user} setUser={setUser}>
            <UserMenu user={user} setUser={setUser} />
        </Header>
    </Layout>
</App>

ปัญหาคือ: ถ้า user เปลี่ยน structure ต้องแก้ทุก component กลาง (Layout, Header) แม้ว่า component เหล่านั้นจะไม่ได้ใช้ user เลย — แค่รับมาแล้วส่งต่อ → โค้ดดูแลยาก, แก้ที่เดียวต้องแก้หลายที่

→ ใช้ Context ได้ แต่...

2. Context re-render ทั้ง subtree

tsx
const AuthContext = createContext({ user, cart, theme });   // ❌

// component ที่ใช้แค่ user → re-render เมื่อ cart เปลี่ยน

แก้ด้วย split context หลายตัว — ยุ่งยาก

3. Logic ซับซ้อน (multiple actions, derived state)

คำศัพท์: derived state (state ที่คำนวณมาจาก state อื่น เช่น total ที่มาจาก items — ไม่ต้องเก็บแยก คำนวณสด ๆ ได้)

tsx
function CartContext() {
    const [items, setItems] = useState([]);
    const total = items.reduce(...);
    const discountedTotal = applyDiscount(total, coupon);
    
    function addItem(item) { ... }
    function removeItem(id) { ... }
    function applyCoupon(code) { ... }
    function clear() { ... }
    
    // ... 200 บรรทัด
}

4. ทดสอบยาก — state ผูกกับ React tree

→ State management library แก้ปัญหาเหล่านี้


2. Server State vs Client State ​

ก่อนเลือก library — ต้องแยกประเภท state:

Server StateClient State
มาจากAPI / DBuser input, UI state
ตัวอย่างuser list, product list, postform input, modal open, theme, sidebar collapsed
เก็บที่ไหนserver (เราแค่ cache)browser memory
Syncต้อง refetch / invalidatelocal เปลี่ยน → ที่อื่นเปลี่ยนตาม
LibraryTanStack Query (บทที่ 4)Zustand / Context

⚠️ อย่าใส่ server state ใน Zustand/Redux — TanStack Query มี cache + refetch ดีกว่า

💡 TanStack Query คืออะไร: library จัดการ data ที่มาจาก API (มี cache, loading state, refetch อัตโนมัติ) — เรียนละเอียดในบท 4 แต่ตอนนี้รู้แค่นี้พอ
TanStack Query + Zustand ใช้คู่กันได้ดีมาก: TanStack Query จัดการ server state, Zustand จัดการ client state — ไม่ต้องเลือกแค่อย่างเดียว

tsx
// ❌ ผิด
useStore(state => state.users)   // fetch ใน Zustand action

// ✅ ถูก
useQuery({ queryKey: ['users'], queryFn: fetchUsers })   // ใน TanStack Query

// Zustand เก็บแค่ client state
useStore(state => state.cart)
useStore(state => state.theme)
useStore(state => state.sidebarOpen)

3. ตัวเลือกในปี 2026 ​

ตัวเลข "ขนาด" เป็น order of magnitude (ขนาดคร่าว ๆ ระดับ 10 เท่า) จากแต่ละ library — เปลี่ยนตาม version + ว่า bundle peer dep (เช่น react-redux) ด้วยหรือเปล่า

ก่อนใส่ลง PR/Architecture decision ให้เช็ค bundlephobia.com หรือรัน bundle analyzer ของ project จริง (last reviewed: 2026-06)

Libraryขนาดคร่าวสไตล์ใช้เมื่อ
useContext + useState0React built-instate เล็ก, share 2-3 level
Zustand~เล็ก (≈2-3 KB)global store, simple⭐ ทั่วไป — default
Jotai~เล็ก (≈4 KB)atom-based (small pieces)state กระจายเป็นชิ้นเล็ก ๆ
Redux Toolkit~กลาง (≈15 KB รวม react-redux)flux pattern, รวมศูนย์โปรเจกต์ใหญ่ + ทีมที่คุ้น Redux
MobX~กลาง (≈15 KB)observable, reactiveOOP, ทำ form state ซับซ้อน
TanStack Query~กลาง (≈13 KB)server state เฉพาะ⭐ ทุก app ที่เรียก API

คำศัพท์: flux = รูปแบบจัดการ state ที่ข้อมูลไหลทางเดียว (action → store → view) ทำให้ตามรอยการเปลี่ยน state ได้ง่าย Redux ยึดแนวนี้

คำแนะนำ:

  • Server state → TanStack Query (เสมอ)
  • Client state ทั่วไป → Zustand (default)
  • Atom-fine-grained → Jotai
  • Boilerplate ที่ทีมรู้แล้ว → Redux Toolkit

💡 หมายเหตุ: "Redux" ในปี 2026 = Redux Toolkit เสมอ — อย่าเริ่มโปรเจกต์ใหม่ด้วย plain Redux (action creator/reducer แยกไฟล์) แบบที่เห็นใน tutorial เก่าก่อน 2020 RTK คือ Redux ทางการรุ่นใหม่ที่ครอบ best practice ทุกอย่างให้แล้ว


Part 1: Zustand — ⭐ มาตรฐานปี 2026 ​

4. Setup ​

bash
npm install zustand

⭐ Zustand v5 (2024+) breaking change: ต้องใช้ syntax create<T>()(...) (มีวงเล็บเปล่า) แทน create<T>(...) เดิม — เพื่อให้ TS infer ได้ถูกต้องเมื่อใช้ middleware

💡 ยังไม่เข้าใจว่าทำไมมี () ซ้อนกันก็ไม่เป็นไร — มองว่าเป็น syntax พิเศษที่ต้องจำไว้เฉยๆ ตอนนี้แค่ copy ตามรูปแบบนี้ได้เลย (เหตุผลจริงคือช่วยให้ TypeScript คำนวณ (infer) type ได้ถูกต้องเวลาใช้ middleware ต่อกันหลายตัว — รายละเอียดลึกไม่จำเป็นสำหรับตอนนี้)

โครงสร้างไฟล์แนะนำ: สร้างโฟลเดอร์ src/store/ แล้วสร้างไฟล์ store ตามต้องการ — เช่น src/store/counter.ts, src/store/cart.ts (1 store ต่อ 1 domain)


5. Store แรก ​

tsx
// store/counter.ts
import { create } from 'zustand';

interface CounterState {
    count: number;
    increment: () => void;
    decrement: () => void;
    reset: () => void;
}

// ⭐ v5 syntax: create<T>()((set) => ...) — มีวงเล็บเปล่า `()` คั่นระหว่าง generic กับ initializer
export const useCounterStore = create<CounterState>()((set) => ({
    count: 0,
    increment: () => set((state) => ({ count: state.count + 1 })),
    decrement: () => set((state) => ({ count: state.count - 1 })),
    reset: () => set({ count: 0 }),
}));
tsx
// Counter.tsx
function Counter() {
    // ⭐ select แต่ละ field แยก → re-render เฉพาะตอน field นั้นเปลี่ยน
    const count = useCounterStore((state) => state.count);
    const increment = useCounterStore((state) => state.increment);
    const decrement = useCounterStore((state) => state.decrement);
    const reset = useCounterStore((state) => state.reset);

    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={increment}>+</button>
            <button onClick={decrement}>-</button>
            <button onClick={reset}>Reset</button>
        </div>
    );
}

⚠️ อย่าทำ: const { increment, decrement } = useCounterStore() — subscribe ทั้ง store → component re-render ทุกครั้งที่ state อะไรก็ตามเปลี่ยน (ดู §6 selector)

re-render ทุกครั้ง = component วาดหน้าจอใหม่บ่อยเกินจำเป็น → UI กระตุก, performance แย่

ผลกระทบนี้จะชัดเจนขึ้นโดยเฉพาะถ้า store ใหญ่และมี component หลายตัว subscribe อยู่พร้อมกัน

ที่ดี:

  • ไม่ต้อง <Provider> ครอบ App
  • เลือก state ได้ตรง — component re-render เฉพาะที่ state ใช้เปลี่ยน
  • Type-safe เต็มที่
  • API ง่ายมาก

6. Selector — performance ​

กุญแจสำคัญของ performance ใน Zustand คือ "selector" — ถ้า subscribe ทั้ง store component จะ re-render ทุกครั้งที่ state ใด ๆ เปลี่ยน ให้ select เฉพาะค่าที่ใช้ (และใช้ useShallow เมื่อ select หลายค่า) เพื่อให้ re-render เฉพาะตอนค่านั้นเปลี่ยนจริง:

tsx
// ❌ subscribe ทั้ง store → re-render ทุกครั้ง state เปลี่ยน
const store = useCounterStore();
const { count } = store;

// ✅ select แค่ที่ใช้
const count = useCounterStore((state) => state.count);

หลายค่า — ใช้ useShallow ​

💡 useShallow มาพร้อมกับ package zustand ที่ install ไปแล้ว ไม่ต้อง install เพิ่ม — แค่ import จาก 'zustand/react/shallow'

tsx
import { useShallow } from 'zustand/react/shallow';

const { count, reset } = useCounterStore(
    useShallow((state) => ({ count: state.count, reset: state.reset }))
);
// re-render เฉพาะตอน count หรือ reset เปลี่ยน (shallow compare)

💡 ทำไม useShallow ถึงสำคัญ: ไล่ทีละขั้น

  1. ทุก render เราคืน object ใหม่ ({ count, reset })
  2. default equality check ของ Zustand (Object.is) มองว่า object ใหม่นี้ "ต่างเสมอ" จาก object ก่อนหน้า (คนละที่อยู่ใน memory)
  3. ผลคือ re-render ทุกครั้ง แม้ค่าข้างในจะเหมือนเดิม

useShallow เปลี่ยนเป็น shallow compare (เทียบเฉพาะระดับบนสุด) — ถ้าทุก key/value Object.is กัน = ถือว่าเท่า → ไม่ re-render

เลือก primitive เดี่ยว → ไม่ต้องใช้ useShallow ​

tsx
// ✅ คืน primitive — Object.is เปรียบเทียบได้ตรงๆ
const count = useCounterStore((s) => s.count);
const isMax = useCounterStore((s) => s.count >= 100);

Anti-pattern ที่เจอบ่อย ​

tsx
// ❌ array literal (การเขียน [...] = JS สร้าง array ใหม่ทุกครั้งที่ run บรรทัดนี้ → ต่างที่อยู่ใน memory)
//    → Object.is([1,2], [1,2]) === false → re-render เสมอ
const items = useStore((s) => s.items.filter(i => i.active));

// ✅ ทาง 1: คืน reference เดิม + compute ข้างนอก
const items = useStore((s) => s.items);
const active = useMemo(() => items.filter(i => i.active), [items]);

// ✅ ทาง 2: useShallow ถ้าจำเป็นต้อง filter ใน selector
const active = useStore(useShallow((s) => s.items.filter(i => i.active)));

6.5 URL เป็น state ที่ดีที่สุด — อย่าเก็บใน Zustand ถ้าไม่จำเป็น ​

state บางอย่างที่หลายคนเผลอเก็บใน store แต่ "ควรอยู่ใน URL" มากกว่า:

  • filter, search query, sort, page number
  • tab ที่เลือก, modal ที่เปิด (อันที่อยากให้ shareable)
  • selected item id

ข้อดีของเก็บใน URL:

  • Shareable — copy link แล้วส่งเพื่อน → state เหมือนกัน
  • Browser back/forward ใช้ได้ — เปลี่ยน filter แล้ว back → กลับ filter เดิม
  • Refresh ไม่หาย — ไม่ต้อง persist
  • SSR-friendly (เข้ากับ Server-Side Rendering — server อ่าน URL ได้ทันที ไม่ต้องรอ JS)
tsx
// ❌ เก็บใน Zustand
const filter = useStore((s) => s.filter);

// ✅ เก็บใน URL ผ่าน React Router v7
// (ตัวอย่างนี้ใช้ React Router — บท 5 — ถ้ายังไม่ได้ตั้ง Router ในโปรเจกต์ ดูบท 5 ก่อน)
import { useSearchParams } from 'react-router';
function ProductList() {
    const [params, setParams] = useSearchParams();
    const filter = params.get('filter') ?? 'all';
    return <button onClick={() => setParams({ filter: 'active' })}>...</button>;
}

กฎ: state ที่อยากให้ share/bookmark/refresh ได้ → URL · state ใช้ภายใน session/process → Zustand · state จาก API → TanStack Query


7. ตัวอย่างจริง — Shopping Cart Store ​

ตัวอย่างจริงที่รวมทุกอย่าง — Shopping Cart store ที่มี action (add/remove/update), derived value (total ที่คำนวณจาก items), และ persist middleware ที่ save ลง localStorage อัตโนมัติ เห็นได้ว่า global state ที่ซับซ้อนเขียนสั้นและตรงไปตรงมากับ Zustand:

📖 Omit<T, K> คืออะไร: utility type ของ TypeScript ที่สร้าง type ใหม่จาก T โดย "ตัด" field ชื่อ K ออก — เช่น Omit<CartItem, 'quantity'> ด้านล่างคือ type เหมือน CartItem ทุกอย่างแต่ไม่มี field quantity (เพราะตอนเพิ่มของใหม่ quantity เริ่มที่ 1 เสมอ ไม่ต้องให้ผู้เรียกใส่มา)

tsx
// store/cart.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';   // middleware ทั้งหมดของ Zustand (persist, devtools, immer*) import จาก 'zustand/middleware' — รายละเอียดใน §8
                                                // *immer ต้อง import จาก 'zustand/middleware/immer' แยก (ดู §8)

interface CartItem {
    id: number;
    name: string;
    price: number;
    quantity: number;
}

interface CartState {
    items: CartItem[];
    addItem: (item: Omit<CartItem, 'quantity'>) => void;   // Omit<CartItem, 'quantity'> = type เหมือน CartItem แต่ตัด field 'quantity' ออก (เพราะตอนเพิ่มของ quantity เริ่มที่ 1 เสมอ)
    removeItem: (id: number) => void;
    updateQuantity: (id: number, qty: number) => void;
    clear: () => void;
    
    // Derived (computed) — เขียนเป็น function ก็ได้
    total: () => number;
}

export const useCartStore = create<CartState>()(
    persist(                              // ⭐ save → localStorage
        (set, get) => ({
            items: [],
            
            addItem: (item) => set((state) => {
                const existing = state.items.find(i => i.id === item.id);
                if (existing) {
                    return {
                        items: state.items.map(i =>
                            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
                        )
                    };
                }
                return { items: [...state.items, { ...item, quantity: 1 }] };
            }),
            // 📝 production จริง ควร validate ว่า price/quantity เป็นบวกก่อนรับเข้า store (ตัวอย่างนี้ตัดออกเพื่อความสั้น)
            
            removeItem: (id) => set((state) => ({
                items: state.items.filter(i => i.id !== id)
            })),
            
            updateQuantity: (id, qty) => set((state) => ({
                items: state.items.map(i => i.id === id ? { ...i, quantity: qty } : i)
            })),
            
            clear: () => set({ items: [] }),
            
            total: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
        }),
        { name: 'cart-storage' }          // localStorage key
    )
);

ใช้:

tsx
function CartIcon() {
    const itemCount = useCartStore(state => state.items.length);
    return <div>🛒 {itemCount}</div>;
}

function CartSummary() {
    // ✅ pattern ที่ดี: compute ใน selector โดย subscribe items ตรง ๆ
    //    (selector return number — Object.is เปรียบเทียบได้ถูกต้อง)
    // ⚠️ pitfall จริงของ selector คือถ้า return object/array ใหม่ทุกครั้ง เช่น
    //    state.items.filter(...) → Object.is ถือว่าต่าง → re-render เสมอ → ต้องใช้ useShallow หรือ useMemo
    const total = useCartStore(state =>
        state.items.reduce((sum, i) => sum + i.price * i.quantity, 0)
    );
    const clear = useCartStore(state => state.clear);

    return (
        <div>
            <p>Total: ${total.toFixed(2)}</p>
            <button onClick={clear}>Clear cart</button>
        </div>
    );
}

8. Middleware ​

tsx
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';   // immer อยู่ใน sub-path แยก (ต้อง install immer เพิ่ม: npm install immer)

interface StoreState {
    count: number;
    user: { name: string; age: number };
    setName: (name: string) => void;
}

// ⭐ v5: ต้องระบุ generic + วงเล็บเปล่า เพื่อให้ TS infer ผ่าน middleware chain ได้ถูก
export const useStore = create<StoreState>()(
    devtools(
        persist(
            immer((set) => ({
                count: 0,
                user: { name: '', age: 0 },

                // immer — เขียน mutation ได้ (จริง ๆ ทำ immutable ภายใต้)
                setName: (name) => set((state) => {
                    state.user.name = name;
                }),
            })),
            { name: 'my-store' }
        )
    )
);
Middlewareทำอะไร
devtoolsต่อ Redux DevTools — debug ได้
persistsave state ลง localStorage
immerเขียน mutation syntax ได้
subscribeWithSelectorsubscribe ภายนอก React

9. Outside React — เรียกจาก service ​

ข้อดีของ Zustand ที่ Context API ทำไม่ได้: เข้าถึง store จากนอก component ได้

ใช้ useStore.getState() อ่าน/แก้ state ได้จาก service layer (ชั้นโค้ดที่ไม่ใช่ component เช่น api/) หรือ event handler นอก React — โดยไม่ต้องอยู่ใน hook:

📖 websocket = ช่องทางสื่อสารกับ server แบบ real-time สองทิศทาง (server ส่งข้อมูลมาหา client ได้ทันทีโดยไม่ต้องรอ client ขอ) — ยังไม่ต้องรู้รายละเอียดตอนนี้ แค่รู้ว่าเป็นอีกที่หนึ่งที่อาจต้องอ่าน/แก้ store จากนอก component

📖 push notification = การแจ้งเตือนที่ server ส่งมาถึง browser ได้แม้ user จะไม่ได้เปิด tab เว็บอยู่

tsx
// ไม่ต้องอยู่ใน hook
import { useCartStore } from './store/cart';

export function checkout() {
    const items = useCartStore.getState().items;
    const total = useCartStore.getState().total();
    
    apiClient.post('/orders', { items, total });
    
    useCartStore.getState().clear();
}

ใช้ตอน:

  • service layer ที่ไม่ใช่ component
  • event handler นอก React (websocket, push notification)

Part 2: Jotai — Atom-based ​

10. แนวคิด ​

ต่างจาก Zustand — Jotai ไม่มี "store" เดียว มี atom เล็ก ๆ หลายตัว

tsx
import { atom, useAtom } from 'jotai';

const countAtom = atom(0);

function Counter() {
    const [count, setCount] = useAtom(countAtom);
    return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}

11. Derived Atom ​

tsx
const priceAtom = atom(100);
const quantityAtom = atom(2);
const totalAtom = atom((get) => get(priceAtom) * get(quantityAtom));

function Total() {
    const total = useAtomValue(totalAtom);     // read-only
    return <p>Total: {total}</p>;
}

totalAtom คำนวณจาก 2 atom — ถ้า atom ไหนเปลี่ยน total อัปเดตอัตโนมัติ


12. Async Atom ​

จุดเด่นของ Jotai: atom เป็น async ได้ — แค่ให้ atom return Promise (เช่น fetch ข้อมูล)

Jotai รวมกับ Suspense (กลไก React สำหรับ "รอ async" — ดูบท 9) จัดการ loading ให้เอง → ใช้ async state เหมือน atom ธรรมดาที่ derive ต่อได้:

tsx
const userIdAtom = atom(1);
const userAtom = atom(async (get) => {
    const id = get(userIdAtom);
    const res = await fetch(`/api/users/${id}`);
    return res.json();
});

function User() {
    const user = useAtomValue(userAtom);   // Suspense จะ handle loading
    return <div>{user.name}</div>;
}

// ⚠️ สำคัญ: component ที่ใช้ async atom ต้องถูกครอบด้วย <Suspense> เสมอ
// ถ้าไม่มี Suspense boundary จะได้ error จาก Jotai
import { Suspense } from 'react';

function App() {
    return (
        <Suspense fallback={<div>กำลังโหลด...</div>}>
            <User />
        </Suspense>
    );
}

Jotai เข้ากับ Suspense ดี — ดูบทที่ 9 สำหรับตัวอย่างเต็มและการใช้ Suspense กับ error boundary


13. เมื่อไหร่ใช้ Jotai vs Zustand ​

ZustandJotai
Mental model"1 global store""หลาย atom"
Boilerplateน้อยน้อยกว่า
Derived statefunction ใน storeatom จาก atom
Atomic updateทั้ง storeatom เดียว
Best forstate ที่กลุ่มกันstate ที่กระจายเป็นชิ้น (เช่น cell ใน spreadsheet)

ส่วนใหญ่ Zustand ชนะ — Jotai เหมาะกับ niche case


Part 3: Redux Toolkit — Redux ที่ใช้งานได้ ​

14. ทำไม Redux Toolkit ไม่ใช่ Redux เก่า ​

หลายคนเข็ดกับ Redux เพราะ boilerplate (โค้ดซ้ำ ๆ ที่ต้องเขียนทุกครั้ง) เยอะ — แต่ละ feature ต้องมี actions/, reducers/, types/, constants/ แยกไฟล์เต็มไปหมด

Redux Toolkit (RTK) คือ Redux ทางการรุ่นใหม่ที่ตัด boilerplate ออกเกือบหมด + ใช้ immer ให้เขียน mutation (แก้ค่าตรง ๆ) ได้ → Redux กลับมาใช้สนุก:

Redux เก่า = boilerplate เยอะ:

  • 1 feature ต้องมี: actions/, reducers/, types/, constants/ + middleware

Redux Toolkit (RTK) ลบ boilerplate ส่วนใหญ่:

bash
npm install @reduxjs/toolkit react-redux

react-redux = bridge เชื่อม Redux กับ React (ทำให้ component ใช้ store ได้ผ่าน hooks เช่น useSelector, useDispatch) — แยกออกจาก @reduxjs/toolkit ที่เป็น core logic ต้อง install ทั้งคู่เสมอเมื่อใช้ Redux ใน React


15. createSlice ​

หัวใจของ RTK คือ createSlice — รวม state + reducers + actions ของ feature หนึ่งไว้ที่เดียว แล้ว generate action creators ให้อัตโนมัติ ภายในใช้ immer จึงเขียน state.value += 1 (ดูเหมือน mutation) ได้โดยยังเป็น immutable จริง:

tsx
// features/counter/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
    value: number;
}

const counterSlice = createSlice({
    name: 'counter',
    initialState: { value: 0 } as CounterState,
    reducers: {
        increment: (state) => {
            state.value += 1;       // immer ใน — ใช้ mutation ได้
        },
        decrement: (state) => {
            state.value -= 1;
        },
        incrementByAmount: (state, action: PayloadAction<number>) => {
            state.value += action.payload;
        },
    },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;

16. Store + Provider ​

รวม slice ทั้งหมดเป็น store เดียวด้วย configureStore (ตั้ง devtools + middleware ให้อัตโนมัติ) แล้วครอบ app ด้วย <Provider> เพื่อให้ทุก component เข้าถึง store ได้ สังเกตการ export RootState/AppDispatch type ที่จะใช้ทำ typed hooks ในหัวข้อถัดไป:

tsx
// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';

export const store = configureStore({
    reducer: {
        counter: counterReducer,
    },
});

export type RootState = ReturnType<typeof store.getState>;   // ReturnType<...> = ให้ TypeScript ดึง type ของ return value จาก function ให้อัตโนมัติ — ไม่ต้องเขียน interface เองซ้ำ
export type AppDispatch = typeof store.dispatch;
tsx
// main.tsx
import { Provider } from 'react-redux';
import { store } from './app/store';

<Provider store={store}>
    <App />
</Provider>

17. Typed hooks ​

แทนที่จะ import RootState/AppDispatch มา annotate ทุกครั้งที่ใช้ useSelector/useDispatch ให้สร้าง typed hooks กลาง (useAppSelector/useAppDispatch) ครั้งเดียว แล้วใช้ทั่ว app ได้ type-safe โดยไม่ต้องระบุ type ซ้ำ ๆ:

ถ้าใช้ useSelector ตรงๆ ต้องระบุ type ของ state ทุกครั้งที่ใช้ — สร้าง typed hooks ครั้งเดียว แล้วใช้ทั่ว app โดยไม่ต้อง annotate type ซ้ำ:

tsx
// app/hooks.ts
import { useDispatch, useSelector } from 'react-redux';
import { RootState, AppDispatch } from './store';

// react-redux v9+ pattern (.withTypes) — แนะนำ
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();

// pattern เก่า (TypedUseSelectorHook) ยังใช้ได้แต่ deprecated ใน v9
// import { TypedUseSelectorHook } from 'react-redux';
// export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

ใช้:

tsx
import { useAppSelector, useAppDispatch } from '../app/hooks';
import { increment, decrement } from './counterSlice';

function Counter() {
    const count = useAppSelector(state => state.counter.value);
    const dispatch = useAppDispatch();
    
    return (
        <div>
            <p>{count}</p>
            <button onClick={() => dispatch(increment())}>+</button>
            <button onClick={() => dispatch(decrement())}>-</button>
        </div>
    );
}

18. RTK Query — server state ใน Redux ​

💡 ตัวอย่างนี้ซับซ้อน — ถ้ายังไม่พร้อม ข้ามไปได้ก่อน RTK Query มีประโยชน์เฉพาะถ้าใช้ Redux อยู่แล้ว มือใหม่ที่ยังไม่คุ้น RTK ควรใช้ TanStack Query แทน (เรียนง่ายกว่า, ใช้ได้ทั้ง project ที่ไม่มี Redux)

tsx
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

// type เหล่านี้ปกติ define ไว้ใน types/ แยกต่างหาก — ตัวอย่างนี้ inline เพื่อความชัดเจน
interface User { id: number; name: string; email: string; }
interface CreateUserRequest { name: string; email: string; }

export const api = createApi({
    reducerPath: 'api',
    baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
    tagTypes: ['User'],
    endpoints: (builder) => ({
        getUsers: builder.query<User[], void>({
            query: () => '/users',
            providesTags: ['User'],
        }),
        createUser: builder.mutation<User, CreateUserRequest>({
            query: (body) => ({ url: '/users', method: 'POST', body }),
            invalidatesTags: ['User'],
        }),
    }),
});

export const { useGetUsersQuery, useCreateUserMutation } = api;

ใช้:

tsx
function Users() {
    const { data, isLoading } = useGetUsersQuery();
    const [createUser] = useCreateUserMutation();
    
    // ...
}

RTK Query vs TanStack Query:

  • RTK Query — ถ้าใช้ Redux อยู่แล้ว
  • TanStack Query — ทุกกรณีอื่น (เป็น standalone, ดีกว่า)

19. Redux Toolkit ใช้เมื่อไหร่? ​

✅ ทีมเดิมรู้ Redux อยู่แล้ว
✅ Project ใหญ่ — ต้องการ structured pattern
✅ ต้องใช้ Redux DevTools time-travel
✅ มี middleware ที่ซับซ้อน (sagas = pattern จัดการ side effect แบบ generator function, observables = stream ของ event ใช้ RxJS — เทคนิคขั้นสูง ข้ามได้ถ้าเพิ่งเริ่ม — แต่ปี 2026 ลดลง)

❌ Project ใหม่เล็ก ๆ — ใช้ Zustand แทน


Part 4: Decision Framework ​

20. เลือกยังไง? ​

มี state management หลายตัวจนสับสนว่าเลือกอะไร — decision tree นี้สรุปง่าย ๆ: ไม่ต้อง share ใช้ useState, เป็น server state ใช้ TanStack Query, client state เล็กใช้ Context, global state ซับซ้อนใช้ Zustand เลือกตามชนิดของ state ไม่ใช่ตาม hype:

สรุป diagram แบบข้อความ (เผื่อ viewer ไม่ render Mermaid):

  1. ไม่ต้อง share state → useState
  2. ต้อง share + มาจาก API → TanStack Query
  3. ต้อง share + client state เล็ก (2-3 component) → useContext + useState
  4. ต้อง share + ใช้หลายที่ → Zustand (default)
  5. State ใหญ่/ซับซ้อน + ทีมคุ้น Redux → Redux Toolkit | ต้อง fine-grained → Jotai | อื่น ๆ → Zustand

21. ⚠️ Common Pitfalls ​

Pitfallแก้
ใส่ server state ใน Zustandใช้ TanStack Query สำหรับ server state
Subscribe ทั้ง storeใช้ selector — useStore(s => s.x)
ใส่ทุก state ใน global storelocal state ก็พอ — ใช้ useState ใน component
Store ใหญ่เกินแยกเป็นหลาย store ตาม domain
Mutate state ตรง ๆ (ไม่ใช้ immer)ใช้ middleware immer หรือ spread
Reset store ตอน logout ลืมเขียน useStore.getState().reset()

22. Checkpoint ​

📝 หมายเหตุ: checkpoint บทนี้ ตั้งใจไม่มีเฉลย ให้ลองทำเองก่อน ถ้าติดให้ย้อนไปดูตัวอย่างโค้ดในบทแล้วดัดแปลง

🛠️ Checkpoint 7.1 — Zustand Auth Store
สร้าง Zustand store สำหรับ auth:

  • user, token
  • login(email, password) → call API → set state
  • logout() → clear state + localStorage
  • persist middleware เพื่อ keep ตอน reload

🛠️ Checkpoint 7.2 — Cart Store
ทำ shopping cart (ตัวอย่างในข้อ 7) เพิ่ม:

  • Apply coupon (discount %)
  • Save for later (item ยังในระบบ แต่ไม่นับ total)
  • Optimistic UI: ลบของ → ลบทันที (rollback ถ้า server fail)

🛠️ Checkpoint 7.3 — Compare
เอา cart เดิม → port ไป Jotai + Redux Toolkit
เปรียบเทียบ:

  • บรรทัด code
  • ความง่ายในการเขียน test
  • TypeScript inference

23. สรุปบท ​

✅ ใช้ state management เมื่อ state ต้อง share หลายที่ + ใหญ่/ซับซ้อน
✅ แยก server state (TanStack Query) ออกจาก client state (Zustand)
✅ Zustand = default ปี 2026 — เล็ก, ง่าย, type-safe
✅ Selector useStore(s => s.x) — performance สำคัญ
✅ Middleware: persist (localStorage), devtools, immer
✅ Jotai = atom-based — เหมาะกับ state ละเอียดมาก
✅ Redux Toolkit = สำหรับทีมที่คุ้น Redux + project ใหญ่
✅ อย่าใส่ทุก state เป็น global — local state (useState) ก็ดีพอ

24. Alternatives ที่ควรรู้ปี 2026 ​

Libraryจุดเด่นใช้เมื่อ
XStatefinite state machine + visualizeflow ซับซ้อน (wizard, checkout, payment)
Legend Statesignal-based (เหมือน SolidJS)performance สูงสุด, fine-grained reactivity
Valtioproxy-based — mutate ได้ตรง ๆOOP style, ทีมที่ชอบ MobX
TanStack DB ⚠️client-side database + syncoffline-first, collaborative — ดูหมายเหตุ¹
Nanostoresatomic, framework-agnosticshare state ข้าม framework

¹ TanStack DB: ณ ปี 2026 ยังอยู่ระหว่างพัฒนาและ API เปลี่ยนบ่อย — เหมาะสำหรับผู้ที่ติดตาม ecosystem ล่วงหน้า ไม่แนะนำสำหรับ production ก่อนเช็ค repo + version status (last reviewed: 2026-06)

Signal-based ปี 2026 — แนวโน้มใหม่ (ความรู้เพิ่มเติม — ข้ามได้ถ้าเพิ่งเริ่ม) ​

📖 Signal (สัญญาณ) = ค่าที่เปลี่ยนแล้วทุกที่ที่ใช้ react ตามอัตโนมัติ โดยไม่ต้อง subscribe เอง — แนวคิดจาก Solid.js / Svelte 5 / Angular signals

Solid.js, Svelte 5 = JavaScript framework อื่นที่ไม่ใช่ React ที่ใช้ signal เป็นหัวใจของ reactivity system

React 19 ยังไม่มี signal native แต่ ecosystem (Solid, Vue, Svelte 5, Angular) ใช้กันหมด — Legend State + Valtio เป็นทางออกใน React

💡 React ไม่ได้ "ล้าหลัง" — React จงใจใช้ model ต่างออกไป: React Compiler (ยังเป็น RC — Release Candidate, ยังไม่ stable v1.0 — ต้อง opt-in ผ่าน babel-plugin-react-compiler แยก ดู บทที่ 10 §14) auto-memoize component โดยไม่ต้องเขียน useMemo/useCallback เอง ซึ่งแก้ปัญหา re-render ได้เช่นกัน แต่ใช้ compile-time optimization แทน signal runtime นอกจากนั้น React 19 มี useOptimistic, use(), useTransition ที่แก้ปัญหาหลาย use case ที่ signal แก้ได้


← บทที่ 6 | บทที่ 8 → Testing


🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-12