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 + มาจาก APITanStack 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