โหมดมืด
บทที่ 7 — State Management (Zustand, Jotai, Redux Toolkit)
🟡 ระดับ: กลาง
📖 คำศัพท์รวมของบท (อ่านก่อน — บทใช้คำเหล่านี้ตลอด):
- 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 State | Client State | |
|---|---|---|
| มาจาก | API / DB | user input, UI state |
| ตัวอย่าง | user list, product list, post | form input, modal open, theme, sidebar collapsed |
| เก็บที่ไหน | server (เราแค่ cache) | browser memory |
| Sync | ต้อง refetch / invalidate | local เปลี่ยน → ที่อื่นเปลี่ยนตาม |
| Library | TanStack 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 + useState | 0 | React built-in | state เล็ก, 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, reactive | OOP, ทำ 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มาพร้อมกับ packagezustandที่ 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ถึงสำคัญ: ไล่ทีละขั้น
- ทุก render เราคืน object ใหม่ (
{ count, reset })- default equality check ของ Zustand (
Object.is) มองว่า object ใหม่นี้ "ต่างเสมอ" จาก object ก่อนหน้า (คนละที่อยู่ใน memory)- ผลคือ re-render ทุกครั้ง แม้ค่าข้างในจะเหมือนเดิม
useShallowเปลี่ยนเป็น shallow compare (เทียบเฉพาะระดับบนสุด) — ถ้าทุก key/valueObject.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ทุกอย่างแต่ไม่มี fieldquantity(เพราะตอนเพิ่มของใหม่ 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 ได้ |
persist | save state ลง localStorage |
immer | เขียน mutation syntax ได้ |
subscribeWithSelector | subscribe ภายนอก 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
| Zustand | Jotai | |
|---|---|---|
| Mental model | "1 global store" | "หลาย atom" |
| Boilerplate | น้อย | น้อยกว่า |
| Derived state | function ใน store | atom จาก atom |
| Atomic update | ทั้ง store | atom เดียว |
| Best for | state ที่กลุ่มกัน | 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):
- ไม่ต้อง share state → useState
- ต้อง share + มาจาก API → TanStack Query
- ต้อง share + client state เล็ก (2-3 component) → useContext + useState
- ต้อง share + ใช้หลายที่ → Zustand (default)
- 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 store | local 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,tokenlogin(email, password)→ call API → set statelogout()→ clear state + localStoragepersistmiddleware เพื่อ 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 | จุดเด่น | ใช้เมื่อ |
|---|---|---|
| XState | finite state machine + visualize | flow ซับซ้อน (wizard, checkout, payment) |
| Legend State | signal-based (เหมือน SolidJS) | performance สูงสุด, fine-grained reactivity |
| Valtio | proxy-based — mutate ได้ตรง ๆ | OOP style, ทีมที่ชอบ MobX |
| TanStack DB ⚠️ | client-side database + sync | offline-first, collaborative — ดูหมายเหตุ¹ |
| Nanostores | atomic, framework-agnostic | share 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 แก้ได้
🔤 Glossary · 📋 Style guide · 📅 last_verified: 2026-06-12