Skip to main content
Frontend13 min read28/9/2026

Quản lý Client State với Zustand trong Next.js App Router: Nhẹ, Linh hoạt & Chuẩn TypeScript

Hướng dẫn toàn diện sử dụng Zustand trong Next.js App Router: so sánh Zustand vs Redux/Context API, giải quyết lỗi Hydration Mismatch với Persist, tổ chức Store theo Slice Pattern và phân định ranh giới giữa Client State (Zustand) và Server State (TanStack Query).

N

Nguyễn Duy Noa

Frontend Developer

Trong hệ sinh thái React, quản lý trạng thái toàn cục (Global State Management) luôn là đề tài gây nhiều tranh luận. Redux Toolkit tuy mạnh mẽ nhưng lại quá cồng kềnh với nhiều boilerplate code; React Context API thì đơn giản nhưng lại dễ gây ra vấn đề hiệu năng (Re-render toàn bộ cây component con không cần thiết).

Zustand ra đời như một vị cứu tinh:

  • Siêu nhẹ (Dung lượng < 1KB): Không làm nặng bundle của ứng dụng.
  • Không cần Provider bọc ngoài: Có thể đọc và cập nhật state ở bất kỳ đâu (kể cả bên ngoài React component).
  • Atomic Selector: Chỉ component nào thực sự lắng nghe dữ liệu bị thay đổi mới bị re-render.
  • Tích hợp sẵn Middleware mạnh mẽ: Hỗ trợ lưu trữ bền vững (persist vào LocalStorage/SessionStorage), gỡ lỗi qua Redux DevTools, và Immer.

Bài viết này sẽ hướng dẫn bạn từ cách cài đặt, thiết lập Store chuẩn TypeScript cho tới việc khắc phục triệt để lỗi Hydration Mismatch trong Next.js App Router.

1. Cài đặt Zustand

Cài đặt thư viện Zustand bằng package manager:

terminal
bash
# Dùng pnpm
pnpm add zustand

# Hoặc dùng npm / yarn / bun
# npm install zustand
# yarn add zustand

2. Khởi tạo Store cơ bản chuẩn TypeScript

Tạo một store quản lý giao diện người dùng (UI Store) như trạng thái bật/tắt Sidebar, Dark Mode, hoặc Modal thông báo:

src/store/useUiStore.ts
typescript
"use client";

import { create } from "zustand";

interface UiState {
  isSidebarOpen: boolean;
  theme: "dark" | "light";
  activeModal: string | null;
  toggleSidebar: () => void;
  setTheme: (theme: "dark" | "light") => void;
  openModal: (modalId: string) => void;
  closeModal: () => void;
}

export const useUiStore = create<UiState>((set) => ({
  isSidebarOpen: false,
  theme: "dark",
  activeModal: null,

  toggleSidebar: () => set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
  setTheme: (theme) => set({ theme }),
  openModal: (modalId) => set({ activeModal: modalId }),
  closeModal: () => set({ activeModal: null }),
}));

3. Sử dụng trong Component & Kỹ thuật chặn Re-render thừa

Một trong những ưu điểm lớn nhất của Zustand là Atomic Selector. Bạn chỉ nên trích xuất đúng phần dữ liệu mà component đó cần:

src/components/Header.tsx
typescript
"use client";

import { useUiStore } from "@/src/store/useUiStore";

export function Header() {
  // ✅ ĐÚNG: Chỉ lắng nghe isSidebarOpen và toggleSidebar
  // Khi theme thay đổi, Header này HOÀN TOÀN KHÔNG BỊ RE-RENDER!
  const isSidebarOpen = useUiStore((state) => state.isSidebarOpen);
  const toggleSidebar = useUiStore((state) => state.toggleSidebar);

  return (
    <header className="flex items-center justify-between p-4 bg-zinc-900 border-b border-zinc-800">
      <h1 className="text-lg font-bold text-zinc-100">My App</h1>
      <button
        onClick={toggleSidebar}
        className="px-3 py-1.5 text-xs font-semibold rounded-lg bg-zinc-800 text-zinc-200"
      >
        {isSidebarOpen ? "Đóng Menu" : "Mở Menu"}
      </button>
    </header>
  );
}

Facing similar challenges?

I can help you optimize your website — reach out for a free consultation.

4. Khắc phục lỗi Hydration Mismatch khi dùng Middleware Persist

Khi sử dụng middleware persist để lưu state xuống localStorage, trên Server component state luôn mang giá trị khởi tạo mặc định, trong khi trên Client browser state đã được đọc từ localStorage. Sự sai lệch này gây ra lỗi Text content does not match server-rendered HTML (Hydration Mismatch).

Giải pháp: Viết một hook useHydratedStore an toàn cho SSR:

src/store/useAuthStore.ts
typescript
"use client";

import { create } from "zustand";
import { persist, createJSONStorage } from "zustand/middleware";
import { useEffect, useState } from "react";

interface User {
  id: string;
  email: string;
  name: string;
}

interface AuthState {
  user: User | null;
  token: string | null;
  setUser: (user: User | null, token: string | null) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      setUser: (user, token) => set({ user, token }),
      logout: () => set({ user: null, token: null }),
    }),
    {
      name: "auth-storage", // Tên key trong localStorage
      storage: createJSONStorage(() => localStorage),
    }
  )
);

// 🛡️ Hook an toàn chống lỗi Hydration Mismatch trong Next.js
export function useHydratedAuthStore<T>(selector: (state: AuthState) => T): T | undefined {
  const result = useAuthStore(selector);
  const [hydrated, setHydrated] = useState(false);

  useEffect(() => {
    setHydrated(true);
  }, []);

  return hydrated ? result : undefined;
}

5. Mở rộng Store với kiến trúc Slice Pattern

Khi ứng dụng phình to, thay vì nhồi nhét tất cả vào một file store duy nhất, hãy chia nhỏ thành các Slices (như UserSlice, CartSlice, SettingSlice) rồi gộp lại vào một Root Store:

src/store/slices/cartSlice.ts
typescript
import { StateCreator } from "zustand";

export interface CartItem {
  id: string;
  name: string;
  quantity: number;
}

export interface CartSlice {
  cart: CartItem[];
  addToCart: (item: CartItem) => void;
  clearCart: () => void;
}

export const createCartSlice: StateCreator<CartSlice> = (set) => ({
  cart: [],
  addToCart: (item) =>
    set((state) => ({ cart: [...state.cart, item] })),
  clearCart: () => set({ cart: [] }),
});

6. Phân định rõ ràng: Khi nào dùng Zustand, khi nào dùng TanStack Query?

Một sai lầm rất phổ biến của nhiều lập trình viên là dùng Zustand để lưu trữ danh sách bài viết, sản phẩm tải về từ API rồi viết thêm các state loading, error thủ công.

Hãy tuân theo quy tắc phân tách chuẩn:

Tiêu chíZustand (Client State)TanStack Query (Server State)
Bản chất dữ liệuTrạng thái thuần túy của giao diện người dùng trên máy khách.Dữ liệu thuộc quyền sở hữu của Database / Backend.
Ví dụ điển hìnhBật/tắt Sidebar, chọn Dark/Light mode, giỏ hàng tạm, bộ lọc lọc UI, step wizard hiện tại.Danh sách bài viết, chi tiết sản phẩm, thông tin tài khoản user, đơn hàng.
Cơ chế lưu trữGiữ trong RAM trình duyệt, có thể lưu vào LocalStorage.Tự động Caching, StaleTime, Invalidation, Garbage Collection.
Xử lý bất đồng bộKhông cần quản lý loading / error / refetch.Tự động quản lý isLoading, isError, refetch(), retry.

Kết luận

Zustand là mảnh ghép hoàn hảo cho kiến trúc Front-End với Next.js App Router nhờ sự gọn nhẹ, linh hoạt, type-safe và không phụ thuộc vào React Context Provider. Khi kết hợp hài hòa giữa Zustand (Client State) và TanStack Query (Server State), bạn sẽ có một hệ thống quản lý dữ liệu cực kỳ mạnh mẽ, sạch sẽ và dễ bảo trì lâu dài.

ZustandNext.jsApp RouterState ManagementReactTypeScriptFrontendClean Code

Want to discuss further?

If you have specific questions about web performance, React, or want to collaborate on a project — reach out directly.

Send an email