Bạn đã thực sự sử dụng đúng Zustand

Đặt vấn đề
Khi app React lớn dần, bạn sẽ gặp tình trạng "prop drilling" — truyền state qua 3–4 lớp component chỉ để một component con đọc được. Giải pháp thường thấy là useContext, nhưng Context có nhược điểm lớn về hiệu năng: mỗi khi state thay đổi, toàn bộ component con đều re-render, dù chúng không dùng đến phần state đó.
Hãy tưởng tượng một trang dashboard có sidebar, header, và bảng dữ liệu đều đọc từ cùng một Context. Chỉ cần cập nhật một badge thông báo nhỏ trên header — cả sidebar lẫn bảng dữ liệu cũng re-render theo. Với app phức tạp, điều này gây giật lag rõ rệt, đặc biệt trên thiết bị yếu.
Zustand giải quyết cả hai vấn đề: global state không cần Provider bọc ngoài, và chỉ re-render đúng component dùng đến state vừa thay đổi.
npm install zustand
Cách dùng cơ bản
import { create } from 'zustand'
const useCounterStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}))
Dùng trong component:
function Counter() {
const count = useCounterStore((s) => s.count)
const increment = useCounterStore((s) => s.increment)
return (
<div>
<p>{count}</p>
<button onClick={increment}>+1</button>
</div>
)
}
3 thứ quan trọng nhất cần nhớ:
| Mô tả | |
|---|---|
create |
Tạo store — nhận một hàm, trả về custom hook |
set |
Cập nhật state — merge với state hiện tại, không overwrite |
selector |
Hàm truyền vào hook để chọn đúng state cần dùng |
Case 1: Selector — tránh re-render thừa
Đây là điểm khác biệt lớn nhất so với useContext. Khi bạn chỉ chọn đúng state cần dùng, component sẽ không re-render khi các state khác thay đổi.
// Không nên — lấy cả store → re-render mỗi khi bất kỳ thứ gì thay đổi
const store = useUserStore()
// Nên làm — chỉ re-render khi username thay đổi
const username = useUserStore((s) => s.username)
Nếu cần lấy nhiều giá trị cùng lúc, bạn có thể bị cám dỗ viết thế này:
// Tạo object mới mỗi lần render → Zustand nghĩ state luôn thay đổi → re-render liên tục
const { username, email } = useUserStore((s) => ({ username: s.username, email: s.email }))
Vấn đề ở đây: mỗi lần selector chạy, nó trả về một object mới dù username và email không đổi. Zustand so sánh bằng === — hai object khác reference luôn bị coi là khác nhau, dù bên trong giống hệt nhau. Dùng useShallow để so sánh theo từng key thay vì reference:
import { useShallow } from 'zustand/react/shallow'
// useShallow so sánh từng key — chỉ re-render khi username hoặc email thực sự thay đổi
const { username, email } = useUserStore(
useShallow((s) => ({ username: s.username, email: s.email }))
)
Case 2: Async action — gọi API trong store
set có thể dùng trong async function bình thường, không cần middleware hay cấu hình thêm:
const useUserStore = create((set) => ({
user: null,
loading: false,
error: null,
fetchUser: async (id) => {
set({ loading: true, error: null })
try {
const data = await fetch(`/api/users/${id}`).then((r) => r.json())
set({ user: data, loading: false })
} catch (err) {
set({ error: err.message, loading: false })
}
},
}))
Dùng trong component:
function UserProfile({ id }) {
const user = useUserStore((s) => s.user)
const loading = useUserStore((s) => s.loading)
const fetchUser = useUserStore((s) => s.fetchUser)
// fetchUser là stable reference (định nghĩa trong store, không thay đổi giữa các render)
// nên thêm vào dependency array là đúng chuẩn ESLint mà không gây vòng lặp vô hạn
useEffect(() => { fetchUser(id) }, [id, fetchUser])
if (loading) return <p>Đang tải...</p>
return <p>{user?.name}</p>
}
Case 3: Persist — lưu state xuống localStorage
Dùng middleware persist để state không mất sau khi reload trang. Hay dùng cho theme, ngôn ngữ, giỏ hàng, user preferences...
Lưu ý bảo mật: Không nên lưu auth token vào localStorage — dễ bị đánh cắp qua XSS. Token nên được quản lý qua httpOnly cookie ở phía server.
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
const useSettingsStore = create(
persist(
(set) => ({
theme: 'light',
language: 'vi',
setTheme: (theme) => set({ theme }),
setLanguage: (language) => set({ language }),
}),
{
name: 'app-settings', // key trong localStorage
}
)
)
Khi muốn chỉ lưu một số field (không lưu tất cả), dùng partialize:
persist(
(set) => ({
theme: 'light',
tempData: null,
// các state khác
}),
{
name: 'app-settings',
partialize: (state) => ({ theme: state.theme }), // chỉ lưu theme, bỏ qua tempData
}
)
Case 4: Tổ chức store lớn — slice pattern
Khi app lớn, không nên nhét tất cả vào một store. Tách thành nhiều "slice" rồi gộp lại:
// store/userSlice.js
export const createUserSlice = (set) => ({
user: null,
setUser: (user) => set({ user }),
logout: () => set({ user: null }),
})
// store/cartSlice.js
export const createCartSlice = (set) => ({
items: [],
addItem: (item) => set((s) => ({ items: [...s.items, item] })),
removeItem: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
clearCart: () => set({ items: [] }),
})
// store/index.js — gộp tất cả lại
const useStore = create((set, get) => ({
...createUserSlice(set, get),
...createCartSlice(set, get),
}))
export default useStore
Sau khi gộp, tất cả state và action từ mọi slice đều nằm chung trong useStore. Bạn dùng selector như bình thường — không cần biết state đến từ slice nào:
function Header() {
// State từ userSlice
const user = useStore((s) => s.user)
const logout = useStore((s) => s.logout)
// State từ cartSlice — cùng một useStore, khác selector
const itemCount = useStore((s) => s.items.length)
return (
<header>
<span>Xin chào, {user?.name}</span>
<span>Giỏ hàng: {itemCount}</span>
<button onClick={logout}>Đăng xuất</button>
</header>
)
}
Nhiều team export thêm selector riêng để tái sử dụng — tránh viết lại selector ở nhiều component và dễ refactor hơn khi đổi tên field:
// store/selectors.js
export const selectUser = (s) => s.user
export const selectCartItems = (s) => s.items
export const selectItemCount = (s) => s.items.length
// Dùng trong component — gọn hơn và nhất quán toàn project
const user = useStore(selectUser)
const itemCount = useStore(selectItemCount)
Case 5: Dùng state ngoài component
Zustand cho phép đọc và ghi state ở bất cứ đâu — không cần ở trong component hay hook. Hữu ích khi xử lý trong axios interceptor, WebSocket handler, hay các file utility:
// Đọc state hiện tại
const currentUser = useUserStore.getState().user
// Cập nhật state từ bên ngoài component
useUserStore.setState({ user: null })
Subscribe lắng nghe thay đổi — có hai dạng signature:
// Dạng 1: subscribe(listener) — lắng nghe mọi thay đổi của toàn bộ store
// Dùng được ngay, không cần middleware
const unsub = useUserStore.subscribe((state) => {
console.log('Store changed:', state)
})
Dạng 2 với selector cần thêm middleware subscribeWithSelector khi tạo store — nếu không, listener sẽ không bao giờ được gọi mà không có lỗi báo:
import { subscribeWithSelector } from 'zustand/middleware'
// Khai báo store với middleware
const useUserStore = create(
subscribeWithSelector((set) => ({
user: null,
setUser: (user) => set({ user }),
}))
)
// Dạng 2: subscribe(selector, listener) — chỉ kích hoạt khi đúng phần state thay đổi
// selector: chọn phần state muốn theo dõi
// listener: nhận (giá trị mới, giá trị cũ)
const unsub = useUserStore.subscribe(
(state) => state.user, // selector — chỉ theo dõi user
(user, prevUser) => { // listener — chạy khi user thay đổi
console.log('User changed from', prevUser, 'to', user)
}
)
// Luôn hủy subscribe khi không cần nữa để tránh memory leak
unsub()
Case 6: Nested state phức tạp — dùng Immer
Khi update object lồng nhau, code immutable có thể rất dài và dễ quên spread ở tầng giữa — dẫn đến bug ngầm mất data. immer middleware cho phép "mutate" trực tiếp mà vẫn đảm bảo immutability bên dưới:
npm install immer
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
const useStore = create(
immer((set) => ({
user: {
profile: { name: '', avatar: '' },
address: { city: '', district: '' },
},
// Mutate thẳng — Immer lo phần còn lại
setCity: (city) => set((state) => {
state.user.address.city = city
}),
setName: (name) => set((state) => {
state.user.profile.name = name
}),
}))
)
So sánh không dùng Immer:
// Dài dòng, dễ quên spread → mất data ở các field khác
setCity: (city) => set((state) => ({
user: {
...state.user,
address: { ...state.user.address, city },
},
}))
Tổng kết
| Case | Công cụ |
|---|---|
| Store cơ bản | create + set |
| Tránh re-render thừa | Selector + useShallow |
| Gọi API trong store | Async action thông thường |
| Lưu state localStorage | persist middleware |
| App lớn, nhiều domain | Slice pattern |
| Dùng ngoài component | getState() / setState() / subscribe() |
| Nested object phức tạp | immer middleware |
Zustand không có "đúng hay sai" khi tổ chức — nó đủ linh hoạt để bắt đầu đơn giản rồi mở rộng dần. Tuy nhiên không phải lúc nào cũng cần dùng: nếu app chỉ có 1–2 component chia sẻ state, useState + prop hoặc useContext là đủ, không cần thêm dependency. Zustand thực sự tỏa sáng khi state được dùng ở nhiều nơi không liên quan nhau trong cây component — đó mới là lúc nó giải quyết đúng vấn đề.
All Rights Reserved