Giới thiệu
7 phút đọc15 tháng 6, 2026387

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.

N

Nguyễn Nhật Long

@nguyennhatlong1303

State Management trong React: Chọn đúng tool cho đúng việc

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:

JSX
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ự.

React state management architecture diagram showing local state with useState staying inside components, Context API wrapping a component tree, and Zustand store sitting outside the tree with selective subscriptions via arrows, clean flat design with blue and purple color scheme

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:

JavaScript
1import { create } from 'zustand'
2
3const useCartStore = create((set) => ({
4 items: [],
5 totalCount: 0,
6 addItem: (item) => set((state) => ({
7 items: [...state.items, item],
8 totalCount: state.totalCount + 1
9 })),
10 removeItem: (id) => set((state) => ({
11 items: state.items.filter(i => i.id !== id),
12 totalCount: state.totalCount - 1
13 })),
14 clearCart: () => set({ items: [], totalCount: 0 })
15}))

Bây giờ component CartIcon chỉ cần totalCount:

JavaScript
1function CartIcon() {
2 // Chỉ re-render khi totalCount thay đổi
3 // Thêm/xóa item không làm CartIcon re-render nếu count không đổi
4 const totalCount = useCartStore((state) => state.totalCount)
5 return <span>{totalCount}</span>
6}

Và component CartList cần items:

JavaScript
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:

JavaScript
1import { create } from 'zustand'
2import { persist } from 'zustand/middleware'
3
4const useSettingsStore = create(
5 persist(
6 (set) => ({
7 theme: 'light',
8 language: 'vi',
9 setTheme: (theme) => set({ theme }),
10 }),
11 { name: 'user-settings' } // localStorage key
12 )
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:

JavaScript
1import { devtools } from 'zustand/middleware'
2
3const 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):

JavaScript
1import { immer } from 'zustand/middleware/immer'
2
3const 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ý immutability
8 state.user.profile.name = name
9 })
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:

JavaScript
1const initialState = {
2 items: [],
3 totalCount: 0
4}
5
6const useCartStore = create((set) => ({
7 ...initialState,
8 addItem: (item) => set((state) => ({ /* ... */ })),
9 reset: () => set(initialState) // gọi khi logout
10}))

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:

JavaScript
1import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
2
3// Fetch danh sách products
4function 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 refetch
9 })
10
11 if (isLoading) return <Spinner />
12 if (error) return <ErrorMessage />
13 return <ul>{data.map(p => <ProductItem key={p.id} product={p} />)}</ul>
14}
15
16// Mutation + invalidate cache
17function AddProductForm() {
18 const queryClient = useQueryClient()
19
20 const mutation = useMutation({
21 mutationFn: (newProduct) => createProduct(newProduct),
22 onSuccess: () => {
23 // Invalidate để refetch danh sách
24 queryClient.invalidateQueries({ queryKey: ['products'] })
25 }
26 })
27
28 // ...
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.

A flowchart showing the separation of server state and client state in a React app: left side shows TanStack Query handling API calls with cache layer, stale time indicator, and refetch arrows; right side shows Zustand store managing UI state like theme, cart, notifications; both sides connect to React components in the center, modern flat design with green for server state and purple for client state

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)
JavaScript
1// Zustand UI state
2const 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}))
10
11// TanStack Query server state, dùng filter từ Zustand làm queryKey
12function ProductGrid() {
13 const selectedFilters = useUIStore((s) => s.selectedFilters)
14
15 const { data } = useQuery({
16 queryKey: ['products', selectedFilters], // filter thay đổi → refetch
17 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.

FeatureContext APIZustandRedux ToolkitJotai
BoilerplateThấpRất thấpTrung bìnhThấp
PerformanceKém (re-render toàn tree)Tốt (selector)TốtTốt
DevToolsKhôngCó (Redux DevTools)
PersistTự làmBuilt-in middlewareTự làmPlugin
Learning curveDễDễTrung bìnhDễ
Phù hợp choTheme, locale, authApp vừa-lớnApp lớn, team lớnAtomic 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.

NN

Nguyễn Nhật Long

@nguyennhatlong1303

Nguyễ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è!

State Management trong React: Chọn đúng tool cho đúng việc — Stacklog