State Management trong React: Chọn đúng tool cho đúng việc
Từ useState đơn giản đến Zustand, TanStack Query khi nào dùng gì, tại sao, và những sai lầm mình từng mắc phải.
Nguyễn Nhật Long
@nguyennhatlong1303
Sau vài năm làm React, mình nhận ra một điều: phần lớn bug và performance issue trong các dự án không phải do logic phức tạp, mà do chọn sai cách quản lý state. Team dùng Context cho mọi thứ rồi thắc mắc tại sao app re-render liên tục. Hoặc setup Redux cho một app nhỏ rồi dành cả buổi viết boilerplate thay vì viết feature. Bài này mình sẽ đi thẳng vào vấn đề không lý thuyết suông, chỉ là những gì mình đã học được qua thực tế.
State của bạn đang sống ở đâu?
Câu hỏi đầu tiên khi gặp bất kỳ state nào: cái này có cần share không? Nếu không, đừng đưa nó ra global. Đây gọi là colocation state nên ở gần nhất với component cần nó.
Các trường hợp useState là đủ và đúng:
- Form input (giá trị đang gõ)
- Modal open/close
- Tab đang active
- Accordion expand/collapse
Các trường hợp cần global state:
- User auth (ai cũng cần biết user là ai)
- Theme (dark/light)
- Shopping cart
- Notification list
Mình thấy nhiều bạn junior có thói quen đưa mọi thứ vào global store cho... tiện. Kết quả là store phình to, khó debug, và component nào cũng phụ thuộc vào nhau theo cách không cần thiết. Cứ hỏi: "Component khác có cần cái này không?" nếu không, giữ nó local.
Context API đừng dùng sai chỗ
Context API không phải state management solution. Đó là dependency injection mechanism. Sự khác biệt này quan trọng hơn bạn nghĩ.
Context phù hợp cho data ít thay đổi:
- Theme hiện tại
- Locale / ngôn ngữ
- Auth status (logged in hay không)
- Feature flags
Vấn đề cốt lõi của Context: khi value thay đổi, toàn bộ component tree consume context đó sẽ re-render, kể cả những component không dùng đến phần data vừa thay đổi. Không có selector, không có granular subscription.
Nếu bạn đang dùng Context để store notification list mà mỗi giây lại có notification mới đó là recipe cho một app chậm như rùa.
Và đừng để rơi vào Provider Hell:
1<AuthProvider>2 <ThemeProvider>3 <CartProvider>4 <NotificationProvider>5 <UserPreferenceProvider>6 <App />7 </UserPreferenceProvider>8 </NotificationProvider>9 </CartProvider>10 </ThemeProvider>11</AuthProvider>
Nhìn vào cái này mình đã thấy mệt. Và đây là lúc cần đến tool thực sự.

Zustand mình chuyển sang đây và không nhìn lại
Zustand giải quyết đúng vấn đề mà Context không làm được: selective re-render thông qua selector.
Setup cơ bản:
1import { create } from 'zustand'23const useCartStore = create((set) => ({4 items: [],5 totalCount: 0,6 addItem: (item) => set((state) => ({7 items: [...state.items, item],8 totalCount: state.totalCount + 19 })),10 removeItem: (id) => set((state) => ({11 items: state.items.filter(i => i.id !== id),12 totalCount: state.totalCount - 113 })),14 clearCart: () => set({ items: [], totalCount: 0 })15}))
Bây giờ component CartIcon chỉ cần totalCount:
1function CartIcon() {2 // Chỉ re-render khi totalCount thay đổi3 // Thêm/xóa item không làm CartIcon re-render nếu count không đổi4 const totalCount = useCartStore((state) => state.totalCount)5 return <span>{totalCount}</span>6}
Và component CartList cần items:
1function CartList() {2 const items = useCartStore((state) => state.items)3 const removeItem = useCartStore((state) => state.removeItem)4 // ...5}
Hai component subscribe vào hai phần khác nhau của store. Khi items thay đổi nhưng totalCount không đổi, CartIcon không re-render. Đây chính xác là thứ Context không làm được.
Middleware nơi Zustand thực sự tỏa sáng
persist auto save/load từ localStorage:
1import { create } from 'zustand'2import { persist } from 'zustand/middleware'34const useSettingsStore = create(5 persist(6 (set) => ({7 theme: 'light',8 language: 'vi',9 setTheme: (theme) => set({ theme }),10 }),11 { name: 'user-settings' } // localStorage key12 )13)
User refresh page, settings vẫn còn. Không cần viết thêm một dòng code nào.
devtools tích hợp Redux DevTools:
1import { devtools } from 'zustand/middleware'23const useStore = create(4 devtools(5 (set) => ({ /* ... */ }),6 { name: 'MyStore' }7 )8)
Mở Redux DevTools extension lên, bạn thấy toàn bộ action history, state diff, time-travel debugging. Cực kỳ tiện khi debug.
Immer middleware cho phép viết mutable-style update (nhưng thực ra vẫn immutable bên dưới):
1import { immer } from 'zustand/middleware/immer'23const useStore = create(4 immer((set) => ({5 user: { profile: { name: '', age: 0 } },6 updateName: (name) => set((state) => {7 // Viết như mutate nhưng Immer xử lý immutability8 state.user.profile.name = name9 })10 }))11)
Thay vì spread object lồng nhau 3-4 tầng, bạn chỉ cần gán trực tiếp. Anh em nào từng làm việc với nested state sẽ hiểu cái đau này.
Pattern reset store về initial state
Mình hay dùng pattern này để reset store khi user logout:
1const initialState = {2 items: [],3 totalCount: 04}56const useCartStore = create((set) => ({7 ...initialState,8 addItem: (item) => set((state) => ({ /* ... */ })),9 reset: () => set(initialState) // gọi khi logout10}))
Server State đây mới là thứ thường bị nhầm lẫn nhất
Một sai lầm mình thấy rất phổ biến: fetch data từ API rồi nhét vào Zustand store để "quản lý". Nghe có vẻ hợp lý, nhưng thực ra bạn đang tự tay implement một cái cache system rất tệ.
Server state có đặc điểm riêng:
- Nó sống ở server, client chỉ có snapshot tại một thời điểm
- Cần refetch khi stale
- Cần sync khi có mutation
- Cần handle loading/error state
- Cần deduplication (nhiều component cùng fetch cùng endpoint)
TanStack Query (trước là React Query) sinh ra để giải quyết đúng những vấn đề này:
1import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'23// Fetch danh sách products4function ProductList() {5 const { data, isLoading, error } = useQuery({6 queryKey: ['products', { category: 'electronics' }],7 queryFn: () => fetchProducts({ category: 'electronics' }),8 staleTime: 5 * 60 * 1000, // 5 phút trước khi refetch9 })1011 if (isLoading) return <Spinner />12 if (error) return <ErrorMessage />13 return <ul>{data.map(p => <ProductItem key={p.id} product={p} />)}</ul>14}1516// Mutation + invalidate cache17function AddProductForm() {18 const queryClient = useQueryClient()1920 const mutation = useMutation({21 mutationFn: (newProduct) => createProduct(newProduct),22 onSuccess: () => {23 // Invalidate để refetch danh sách24 queryClient.invalidateQueries({ queryKey: ['products'] })25 }26 })2728 // ...29}
Cái hay ở đây: 10 component cùng gọi useQuery(['products']) TanStack Query chỉ thực sự fetch một lần. Kết quả được cache và share. Khi cache stale, nó tự refetch background. Bạn không cần viết một dòng logic nào cho việc này.

Phối hợp Zustand và TanStack Query
Công thức mình đang dùng trong hầu hết project:
- TanStack Query: mọi thứ đến từ API (products, users, orders, ...)
- Zustand: UI state thuần túy (sidebar open/close, selected filters, modal state, user preferences)
1// Zustand UI state2const useUIStore = create((set) => ({3 sidebarOpen: false,4 selectedFilters: { category: null, priceRange: null },5 toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),6 setFilter: (key, value) => set((s) => ({7 selectedFilters: { ...s.selectedFilters, [key]: value }8 }))9}))1011// TanStack Query server state, dùng filter từ Zustand làm queryKey12function ProductGrid() {13 const selectedFilters = useUIStore((s) => s.selectedFilters)1415 const { data } = useQuery({16 queryKey: ['products', selectedFilters], // filter thay đổi → refetch17 queryFn: () => fetchProducts(selectedFilters)18 })19 // ...20}
Khi user thay đổi filter, Zustand update selectedFilters, TanStack Query detect queryKey thay đổi và tự fetch lại. Clean, declarative, không cần useEffect nào.
So sánh nhanh các option
Theo kinh nghiệm của mình, Zustand + TanStack Query là combo đủ mạnh cho 90% các dự án. Redux Toolkit vẫn có chỗ đứng trong enterprise app với team lớn cần convention chặt chẽ nhưng nếu bạn đang start một project mới, đừng mặc định chọn Redux chỉ vì nó quen thuộc.
| Feature | Context API | Zustand | Redux Toolkit | Jotai |
|---|---|---|---|---|
| Boilerplate | Thấp | Rất thấp | Trung bình | Thấp |
| Performance | Kém (re-render toàn tree) | Tốt (selector) | Tốt | Tốt |
| DevTools | Không | Có (Redux DevTools) | Có | Có |
| Persist | Tự làm | Built-in middleware | Tự làm | Plugin |
| Learning curve | Dễ | Dễ | Trung bình | Dễ |
| Phù hợp cho | Theme, locale, auth | App vừa-lớn | App lớn, team lớn | Atomic state |
Jotai thú vị ở mô hình atomic (mỗi atom là một unit state độc lập), nhưng mình chưa thấy use case nào mà Zustand không giải quyết được mà cần đến Jotai. Đó là lựa chọn cá nhân.
Một vài nguyên tắc mình luôn giữ
Không có tool nào là silver bullet. Nhưng có một vài nguyên tắc giúp mình ít phải refactor hơn:
Đừng mix server state vào client store. Nếu data đến từ API, dùng TanStack Query. Zustand không phải cache layer.
Chia store theo domain, không theo technical concern. useCartStore, useAuthStore, useNotificationStore mỗi store có một nhiệm vụ rõ ràng. Đừng có một useGlobalStore chứa tất cả.
Selector phải specific. useStore(s => s.user.profile.name) tốt hơn useStore(s => s.user) nếu bạn chỉ cần name. Selector càng specific, re-render càng ít.
Reset state khi cần. Đặc biệt là khi user logout đừng để data của user cũ còn sót lại trong store.
State management không phải là thứ bạn setup một lần rồi không nghĩ đến nữa. Nó là architecture decision ảnh hưởng đến cả project. Chọn đúng từ đầu sẽ tiết kiệm cho bạn rất nhiều đau đầu về sau.
Nguyễn Nhật Long
@nguyennhatlong1303Nguyễn Nhật Long is a Senior Frontend Engineer and Frontend Team Leader with 7 years of experience building real-time fintech platforms. Specializing in React, Next.js, TypeScript, and React Native, shipping 10+ products across Web, Mobile, Telegram Mini-Apps, and Web3.
Thấy hay? Chia sẻ cho bạn bè!
