Tách Biệt Client & Server State: Tại Sao Chúng Tôi Chọn Zustand Cho Auth và TanStack Query Cho User Profile Thay Vì Redux Hay Context API
Lưu ý: Bài viết dưới đây chia sẻ góc nhìn thực tế từ quá trình thiết kế kiến trúc hệ thống cho các dự án SaaS và Dashboard quy mô lớn, nơi hiệu năng (performance), khả năng mở rộng (scalability) và trải nghiệm lập trình viên (Developer Experience - DX) được đặt lên hàng đầu.
1. Đặt Vấn Đề: Khi "Mọi Thứ" Đều Được Nhét Vào Một Nơi
Nếu đã từng làm việc với các dự án React lớn, kịch bản dưới đây chắc chắn không còn xa lạ với bạn:
Bạn mở thư mục quản lý State toàn cục (global state) ra và thấy một "quái vật" Redux Store hoặc một chuỗi các Context Provider lồng vào nhau vô tận (Provider Hell). Ở đó, biến trạng thái mở của một Modal (isModalOpen) nằm chung hàng ngũ với dữ liệu danh sách hóa đơn từ API (invoiceList), thông tin đăng nhập (accessToken), và cấu hình giao diện tối/sáng (theme).
Sự hỗn loạn này bắt nguồn từ một giả định sai lầm thuở sơ khai của React: Mọi dữ liệu không phải local state đều là global state, và chúng xứng đáng được quản lý bởi cùng một công cụ.
Hệ quả là:
Hiệu năng suy giảm: Một thay đổi nhỏ ở client state (ví dụ: đóng/mở sidebar) kích hoạt re-render hàng loạt component đang hiển thị dữ liệu từ API do cơ chế chia sẻ chung một context hoặc cấu trúc store chưa tối ưu.
Tự viết lại bánh xe (Reinventing the wheel): Chúng ta tốn hàng trăm dòng code boilerplate chỉ để xử lý các bài toán kinh điển của dữ liệu từ server: Loading state, Error state, Caching, Retry khi mất mạng, và đồng bộ hóa dữ liệu ngầm (background refetching).
Nhưng thực tế, không phải state nào cũng giống nhau. Khi chúng ta tách biệt rõ ràng ranh giới giữa chúng, kiến trúc ứng dụng sẽ trở nên cực kỳ thanh thoát. Đó là lý do tại sao chúng tôi lựa chọn Zustand kết hợp với TanStack Query (React Query).
2. Bản Chất Của Sự Khác Biệt: Client State vs. Server State
Để đưa ra các quyết định kiến trúc chuẩn xác, trước tiên ta phải phân định rạch ròi hai khái niệm: Client State và Server State.
┌─────────────────────────────────────────┐
│ React App │
└────────────────────┬────────────────────┘
│
┌───────────────────────────────┴───────────────────────────────┐
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────┐
│ Client State │ │ Server State │
│ (Sở hữu hoàn toàn bởi │ │ (Thực tế nằm ở Server, │
│ client) │ │ client giữ bản cache) │
└────────────┬─────────────┘ └────────────┬─────────────┘
│ │
[Quản lý: Zustand] [Quản lý: TanStack Query]
│ │
- Trạng thái Auth (Tokens) - User Profile & Quyền hạn
- UI State (Theme, Modals) - Workspace & Team Data
- Bộ lọc (Filters, Search) - Chi tiết đơn hàng từ API
Client State
Là trạng thái thuộc quyền sở hữu hoàn toàn của phía client (trình duyệt). Nó mang tính chất đồng bộ (synchronous), tồn tại tạm thời và biến mất khi người dùng tải lại trang (nếu không được lưu trữ lâu dài thông qua Storage).
Ví dụ: Trạng thái đóng/mở Sidebar, Theme (Light/Dark mode), các bộ lọc tìm kiếm cục bộ, và Authentication State (Access token, Refresh token, trạng thái đã đăng nhập hay chưa).
Server State
Là trạng thái thực chất được lưu trữ tại Database phía Server và chỉ được ứng dụng hiển thị lại dưới dạng một bản sao tạm thời (Snapshot/Cache). Trạng thái này mang tính chất bất đồng bộ (asynchronous) và chúng ta không thể tự ý thay đổi nó ở client mà không gửi request lên Server.
Ví dụ: Thông tin chi tiết của User Profile, danh sách quyền hạn (Permissions), danh sách Workspace, lịch sử giao dịch.
Bởi vì Server State chỉ là dữ liệu "mượn tạm", nó đi kèm với những bài toán vô cùng phức tạp mà Client State thông thường không có:
Caching: Làm sao để không gọi API liên tục khi người dùng chuyển trang?
Stale Management: Khi nào thì dữ liệu hiển thị trên màn hình bị coi là "cũ" và cần cập nhật?
Synchronization: Đồng bộ dữ liệu ngầm khi người dùng focus lại vào tab trình duyệt.
Invalidation: Làm sao để ép buộc hệ thống fetch lại dữ liệu mới sau khi người dùng vừa thực hiện hành động cập nhật (Mutation)?
Chìa khóa vàng: Server state không đơn thuần là global state. Việc cố gắng nhét server state vào một thư viện quản lý client state truyền thống giống như việc bạn cố dùng một cái hộp giấy để chứa nước vậy.
3. Tại Sao Chúng Tôi Dùng Zustand Cho Authentication State?
Authentication State (trạng thái đăng nhập) là một trường hợp đặc biệt. Mặc dù thông tin user profile chi tiết là server state, nhưng bản thân token (Access/Refresh Token) và trạng thái "đã xác thực hay chưa" (isAuthenticated) lại là Client State thuần túy. Client cần biết thông tin này ngay lập tức, đồng bộ, để quyết định render Route nào (Protected Route hay Public Route).
Zustand là lựa chọn hoàn hảo cho Auth State nhờ vào các yếu tố sau:
1. Siêu Nhẹ (Lightweight) và Không Boilerplate
Zustand có kích thước bundle size chưa đầy 2KB. Nó không yêu cầu bạn phải viết Reducer, Action Types, Creators, hay cấu hình Store phức tạp. Bạn định nghĩa state và các hàm thay đổi state (actions) ngay trong cùng một nơi.
2. Không Bị "Provider Hell"
Khác với Context API, Zustand không cần bất kỳ Provider nào bọc bên ngoài ứng dụng của bạn. Bạn có thể gọi hook của Zustand ở bất kỳ đâu, thậm chí đọc/ghi trực tiếp vào store từ các file JavaScript/TypeScript thuần túy (như Axios Interceptors) mà không cần React Context.
3. Tối Ưu Re-render Nhờ Selector-based Subscription
Zustand cho phép bạn subcribe chính xác vào từng phần nhỏ của state. Khi phần state đó thay đổi, chỉ những component sử dụng phần đó mới bị re-render.
Dưới đây là một ví dụ thực tế về cách chúng tôi thiết kế một authStore bằng Zustand:
// src/stores/useAuthStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
interface AuthState {
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
setTokens: (accessToken: string, refreshToken: string) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
isAuthenticated: false,
setTokens: (accessToken, refreshToken) =>
set({
accessToken,
refreshToken,
isAuthenticated: true,
}),
clearAuth: () =>
set({
accessToken: null,
refreshToken: null,
isAuthenticated: false,
}),
}),
{
name: 'auth-storage', // Key lưu trữ dưới LocalStorage
storage: createJSONStorage(() => localStorage),
// Chỉ lưu trữ token, không lưu trữ các trạng thái thừa nếu không cần thiết
partialize: (state) => ({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated,
}),
}
)
);
Tại sao Auth State phù hợp với Zustand hơn Redux hay Context?
So với Redux: Quá thừa thãi (overkill). Auth state chỉ có khoảng 3-5 trường dữ liệu và một vài actions đơn giản. Viết Redux cho tác vụ này giống như dùng súng đại bác để diệt muỗi.
So với Context API: Context API không có cơ chế
persisttích hợp sẵn, bạn phải tự viết code đọc/ghilocalStoragetronguseEffect. Hơn nữa, mỗi lần cập nhật token, toàn bộ các Route bọc trong AuthProvider sẽ bị re-render nếu không được bọc lót kỹ lưỡng bằnguseMemo.
4. Tại Sao Chúng Tôi Chọn TanStack Query Cho User Profile?
Nhiều lập trình viên có thói quen sau khi gọi API đăng nhập thành công sẽ lấy luôn thông tin User Profile (như email, avatar, quyền hạn) lưu vào trong Redux hoặc Zustand.
Chúng tôi không làm thế. Chúng tôi lưu thông tin User Profile bằng TanStack Query.
User Profile thực chất là dữ liệu của Server. Nó có thể thay đổi trên database (ví dụ: Admin khóa tài khoản của bạn, bạn được thăng chức từ member lên admin, hoặc bạn thay đổi avatar ở thiết bị khác).
Nếu bạn lưu User Profile vào Zustand, bạn sẽ phải tự mình quản lý:
Gọi API lấy profile khi ứng dụng khởi chạy.
Viết logic refresh profile định kỳ.
Viết logic cập nhật lại Zustand store khi user cập nhật profile ở trang Settings.
TanStack Query giải quyết toàn bộ các vấn đề trên chỉ với một vài dòng code khai báo (declarative code):
// src/hooks/useUserProfile.ts
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
interface UserProfile {
id: string;
email: string;
fullName: string;
avatarUrl: string;
role: 'admin' | 'member';
}
const fetchUserProfile = async (): Promise<UserProfile> => {
const { data } = await axios.get('/api/v1/users/me');
return data;
};
export const useUserProfile = (enabled: boolean) => {
return useQuery<UserProfile, Error>({
queryKey: ['user-profile'],
queryFn: fetchUserProfile,
enabled, // Chỉ fetch khi đã có Access Token (được truyền từ authStore)
staleTime: 1000 * 60 * 5, // Dữ liệu được coi là "fresh" trong 5 phút
gcTime: 1000 * 60 * 30, // Giữ lại trong cache 30 phút trước khi dọn dẹp (garbage collection)
refetchOnWindowFocus: true, // Tự động cập nhật ngầm khi user quay lại tab ứng dụng
retry: 2, // Thử lại tối đa 2 lần nếu lỗi mạng xảy ra
});
};
Nguyên lý kiến trúc cốt lõi: "Don't turn your global state into a second database."
Đừng biến global state (như Redux hay Zustand) của bạn thành một cơ sở dữ liệu thứ hai ở client. Việc cố gắng đồng bộ hóa thủ công giữa DB và Store là một nguồn gốc cực kỳ phổ biến gây ra các bug "out of sync" (giao diện hiển thị một đằng, dữ liệu thực tế một nẻo).
Với TanStack Query, dữ liệu được tự động hóa việc caching và invalidation. Khi User đổi avatar, chúng ta chỉ cần ra lệnh cho cache: "Hãy làm trống cache của ['user-profile'] đi", và TanStack Query sẽ tự lo phần còn lại.
5. Tại Sao Không Phải Là Context API?
Context API của React rất tuyệt vời cho các tác vụ đơn giản như lưu cài đặt Theme (Sáng/Tối) hay Language (Đa ngôn ngữ) – những dữ liệu cực kỳ ít khi thay đổi (low-frequency updates).
Nhưng đối với các ứng dụng có tính tương tác cao hoặc quy mô lớn, Context API bộc lộ rõ những hạn chế:
Vấn đề Re-render ngoài tầm kiểm soát: Khi giá trị của Context Provider thay đổi, tất cả các component tiêu thụ (consume) Context đó thông qua
useContextđều bị re-render bắt buộc, bất kể chúng chỉ sử dụng một phần nhỏ không thay đổi trong Context đó.Nesting Hell (Địa ngục lồng Provider):
<ThemeProvider> <LanguageProvider> <AuthProvider> <SocketProvider> <DashboardLayout /> </SocketProvider> </AuthProvider> </LanguageProvider> </ThemeProvider>Kiến trúc này cực kỳ khó đọc, khó bảo trì và gây cản trở khi bạn muốn viết Unit Test độc lập cho các Component con.
Logic phân mảnh: Context không cung cấp giải pháp quản lý state hướng cấu trúc tốt. Bạn thường phải tự viết thêm các cặp
useState+useCallbackrườm rà để truyền xuống qua value, tạo ra một đống code hỗn độn ngay trong file component định nghĩa Provider.
6. Tại Sao Không Phải Là Redux?
Trước tiên, hãy công bằng: Redux vẫn là một công cụ xuất sắc. Nó sở hữu một kiến trúc vô cùng chặt chẽ, dễ dự đoán, hệ sinh thái công cụ hỗ trợ (như Redux DevTools) thuộc hàng tốt nhất thế giới và cực kỳ phù hợp cho các dự án Enterprise cấp độ siêu lớn, nơi hàng trăm kỹ sư cùng làm việc trên một codebase.
Tuy nhiên, đối với phần lớn các dự án web hiện nay, Redux mang lại cảm giác "over-engineering" vì những lý do:
Hành lý Boilerplate cồng kềnh: Dù Redux Toolkit (RTK) đã giúp giảm thiểu đáng kể lượng code mẫu, việc thiết lập một luồng xử lý bất đồng bộ (async thunk) vẫn đòi hỏi quá nhiều bước từ định nghĩa slice, extraReducers, dispatch action, đến quản lý loading state thủ công.
RTK Query mạnh nhưng phức tạp: RTK Query là câu trả lời của Redux cho Server State. Nó hoạt động rất tốt, nhưng triết lý thiết kế của nó đi kèm với Redux vẫn tạo cảm giác nặng nề và có dốc học tập (learning curve) cao hơn nhiều so với sự trực quan của TanStack Query.
Triết lý chọn công cụ: "Choosing tools is about trade-offs, not popularity." (Chọn công cụ là về sự đánh đổi, không phải vì độ nổi tiếng). Nếu chúng tôi có thể đạt được $95\%$ sức mạnh của Redux với chỉ $10\%$ lượng code và công sức thiết lập bằng cách kết hợp Zustand + TanStack Query, thì đó là một món hời lớn về mặt chi phí kỹ thuật (engineering cost).
7. Kiến Trúc Thực Tế: Khi Zustand Đồng Hành Cùng TanStack Query
Hãy cùng xem hai thư viện này phối hợp nhịp nhàng như thế nào trong một ứng dụng thực tế.
Dưới đây là một sơ đồ mô tả luồng giao tiếp giữa Auth Store (Zustand), API Client (Axios Interceptor), và User Profile (TanStack Query):
┌────────────────────────┐
│ Zustand: useAuthStore │ <─── (1) Cung cấp Access Token
└───────────┬────────────┘
│
▼
┌────────────────────────┐
│ Axios Interceptor │ <─── (2) Gắn Token vào Headers của API Request
└───────────┬────────────┘
│
▼
┌────────────────────────┐
│ TanStack Query │ <─── (3) Fetch `/me` & Quản lý Cache
│ (useUserProfile) │
└────────────────────────┘
Và đây là code triển khai thực tế của kiến trúc này:
Bước 1: Cấu hình Axios Interceptor đọc trực tiếp từ Zustand Store
Điểm đặc biệt của Zustand là chúng ta có thể gọi useAuthStore.getState() bên ngoài môi trường React Component (trong các file JS/TS thuần).
// src/api/axiosClient.ts
import axios from 'axios';
import { useAuthStore } from '../stores/useAuthStore';
export const axiosClient = axios.create({
baseURL: 'https://api.yourdashboard.com/v1',
headers: {
'Content-Type': 'application/json',
},
});
// Request Interceptor: Tự động đính kèm token vào request
axiosClient.interceptors.request.use(
(config) => {
// Đọc token trực tiếp từ Zustand store không cần React hooks!
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response Interceptor: Xử lý khi Token hết hạn (401 Unauthorized)
axiosClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// Gọi API refresh token ở đây...
// Sau đó cập nhật lại store bằng useAuthStore.getState().setTokens(...)
return axiosClient(originalRequest);
} catch (refreshError) {
// Nếu refresh thất bại, xóa credentials và đá ra màn đăng nhập
useAuthStore.getState().clearAuth();
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
Bước 2: Tích hợp vào Giao Diện (Dashboard Component)
Trong component giao diện chính, chúng ta kết hợp trạng thái đăng nhập từ Zustand để quyết định gọi API lấy Profile từ TanStack Query.
// src/App.tsx
import React from 'react';
import { useAuthStore } from './stores/useAuthStore';
import { useUserProfile } from './hooks/useUserProfile';
export default function App() {
const { isAuthenticated, clearAuth, setTokens } = useAuthStore();
// Chỉ kích hoạt query fetch profile khi user đã thực sự đăng nhập
const { data: user, isLoading, isError, error } = useUserProfile(isAuthenticated);
// Mock hành động login thành công
const handleLoginMock = () => {
setTokens('mock-access-token-123', 'mock-refresh-token-456');
};
if (!isAuthenticated) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-100">
<div className="p-8 bg-white rounded-lg shadow-md max-w-sm w-full text-center">
<h2 className="text-2xl font-bold mb-4 text-slate-800">Chào Mừng Quay Lại</h2>
<button
onClick={handleLoginMock}
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded transition duration-200"
>
Đăng Nhập Thử Nghiệm
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-slate-50 flex">
{/* Sidebar */}
<aside className="w-64 bg-slate-900 text-slate-100 p-6 flex flex-col justify-between">
<div>
<h1 className="text-xl font-bold mb-8">SaaS Admin</h1>
<nav className="space-y-2">
<a href="#dashboard" className="block py-2 px-3 bg-slate-800 rounded font-medium">Dashboard</a>
<a href="#settings" className="block py-2 px-3 text-slate-400 hover:bg-slate-800 rounded transition">Cài đặt</a>
</nav>
</div>
{/* User Profile Footer trong Sidebar */}
<div className="border-t border-slate-800 pt-4">
{isLoading ? (
<div className="animate-pulse flex items-center space-x-3">
<div className="rounded-full bg-slate-700 h-10 w-10"></div>
<div className="flex-1 space-y-2 py-1">
<div className="h-2 bg-slate-700 rounded"></div>
<div className="h-2 bg-slate-700 rounded w-5/6"></div>
</div>
</div>
) : isError ? (
<div className="text-red-400 text-xs">Lỗi lấy thông tin cá nhân</div>
) : user ? (
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<img className="h-10 w-10 rounded-full border border-slate-700" src={user.avatarUrl || 'https://via.placeholder.com/150'} alt="Avatar" />
<div>
<p className="text-sm font-semibold">{user.fullName}</p>
<p className="text-xs text-slate-400 capitalize">{user.role}</p>
</div>
</div>
<button
onClick={clearAuth}
className="text-slate-400 hover:text-red-400 p-1 rounded transition"
title="Đăng xuất"
>
🚪
</button>
</div>
) : null}
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 p-10">
<h2 className="text-3xl font-bold text-slate-800 mb-6">Bảng Điều Khiển</h2>
<div className="bg-white p-6 rounded-lg shadow-sm border border-slate-100">
<p className="text-slate-600">Dữ liệu bảo mật của bạn đang được hiển thị an toàn tại đây.</p>
</div>
</main>
</div>
);
}
8. Lợi Ích Về Hiệu Năng & Khả Năng Mở Rộng
Khi áp dụng mô hình kiến trúc phân tách rõ ràng này, chúng tôi nhận thấy những cải tiến vượt trội ở 4 khía cạnh:
Tiêu Chí | Kiến Trúc Cũ (Tất cả trong Redux / Context) | Kiến Trúc Mới (Zustand + TanStack Query) |
|---|---|---|
Tốc Độ Phát Triển (DX) | Chậm do phải viết nhiều boilerplate cho mỗi API mới. | Cực nhanh. Thêm API mới chỉ cần viết thêm một Hook |
Tần Suất Re-render | Toàn bộ ứng dụng bị ảnh hưởng khi có bất kỳ sự thay đổi nhỏ nào trong store lớn. | Tối thiểu. Nhờ cơ chế Selector của Zustand và quản lý Cache độc lập của Query. |
Khả Năng Đồng Bộ Dữ Liệu | Phức tạp, phải viết nhiều logic thủ công để refetch hoặc cập nhật store. | Tự động hóa hoàn toàn nhờ các trigger: focus window, reconnection, mutations. |
Dung Lượng Bundle Size | Lớn (Redux + Middleware + Async Thunk Logic). | Siêu nhỏ gọn (~2KB cho Zustand, ~12KB cho TanStack Query). |
9. Kết Luận
Trong phát triển phần mềm, không có công cụ nào được coi là "tốt nhất" một cách tuyệt đối, chỉ có những công cụ phù hợp nhất cho từng bài toán cụ thể.
Việc chuyển dịch từ mô hình quản lý tập trung kiểu cũ (Redux-for-everything) sang mô hình phân tách trách nhiệm (Separation of Concerns) với Zustand đảm nhận Client State và TanStack Query làm chủ Server State đã giúp chúng tôi giải phóng được một lượng lớn chi phí bảo trì code thừa, tăng tốc đáng kể trải nghiệm của lập trình viên và quan trọng nhất là mang lại một giao diện mượt mà hơn cho người dùng cuối.
Hãy luôn ghi nhớ chân lý kỹ thuật này trước khi bắt tay thiết lập dự án tiếp theo của bạn:
Different problems deserve different tools. (Những vấn đề khác nhau xứng đáng có những công cụ khác nhau). Đừng ép buộc bản thân phải đi theo những lối mòn cũ khi các công cụ hiện đại đã mở ra những giải pháp tối ưu và tinh tế hơn rất nhiều.
Cảm ơn các bạn đã theo dõi bài viết. Bạn đang quản lý State trong dự án của mình như thế nào?


