Đang tải...

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

06/05/2026
9 phút đọc
Bạn đã thực sự sử dụng đúng Zustand
![image.png](https://images.viblo.asia/5d7a4e23-2cd7-4236-bb09-56a9e91e2f1e.png) ## Đặ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 ...

image.png

Đặ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ớiusernameemail 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 đề.

📚 Nguồn: Viblo

Chia sẻ bài viết

Cần tư vấn?

Liên hệ với chúng tôi để được hỗ trợ

Liên hệ ngay

Bài viết liên quan

Tích hợp đa ngôn ngữ vào Next.js (App Router)
06/05/2026

Tích hợp đa ngôn ngữ vào Next.js (App Router)

![image.png](https://images.viblo.asia/b7071922-d902-468b-99ef-953e94eee8c5.png) ## Giới thiệu Khi xây dựng các dự án hướng đến thị trường toàn cầu hoặc các dịch vụ c?...

Đọc thêm
Hướng dẫn cài đặt Synology Office MCP Server: cho Claude "đọc — ghi" trực tiếp NAS Synology
06/05/2026

Hướng dẫn cài đặt Synology Office MCP Server: cho Claude "đọc — ghi" trực tiếp NAS Synology

Nếu bạn đang dùng Synology NAS làm nơi lưu trữ chính cho tài liệu, bảng tính, email, lịch — và bạn cũng đang xài Claude Desktop hoặc Claude Code hằng ngày — thì s?...

Đọc thêm
Vì Sao Hệ Thống Notification Luôn Cần Queue?
06/05/2026

Vì Sao Hệ Thống Notification Luôn Cần Queue?

Khi xây dựng hệ thống notification: * Push notification * SMS * Email * Zalo * Telegram nhiều người thường làm kiểu: ``` User Action ↓ Gửi notification trực tiếp ``` Ban đ?...

Đọc thêm

Bắt đầu dự án của bạn

Hãy để Flash Dev đồng hành cùng bạn

Liên hệ ngay