📚 全栈开发学习系列
从零到全栈 · 8 个阶段 · 25+ 篇
✅ 阶段一:编程基础 01-08
路线总览 · Python · C语言 · 数据结构 · Git · Linux
✅ 阶段二:Web开发基础 09-16
HTML · CSS · JavaScript · HTTP · FastAPI · PostgreSQL · 认证授权 · 综合实战
阶段三:前端深化 17-25 · 进行中
✓ 17 React入门:组件化思想与JSX语法
✓ 18 React进阶:状态管理与副作用钩子
✓ 19 TypeScript入门:类型系统与类型注解
✓ 20 Next.js基础:SSR与路由体系
✓ 21 Tailwind CSS入门:实用优先的CSS框架
✓ 22 前端工程化:Vite与Webpack构建工具
✓ 23 前端测试:Vitest与Playwright
✓ 24 API测试:接口自动化与Mock服务
▸ 25 状态管理:Zustand与服务端状态(当前)

组件层级变深后,useState 逐层传递 props 的链路越来越长。更要命的是,API 返回的数据被多个组件共享——谁负责缓存?过期了谁重新拉取?用户改了数据谁同步?

本篇把状态分为两类:客户端状态用 Zustand 5.0(主题切换、购物车),服务端状态用 TanStack Query v5.102(API 数据缓存、自动刷新)。读完本篇你将能设计合理的全局状态架构,避免"所有数据塞进一个 Redux"的常见错误。

📋 目录
1. 客户端状态 vs 服务端状态
2. React内置状态的瓶颈
3. Zustand 5.0:轻量全局状态
4. 创建第一个Store
5. 选择性订阅与性能优化
6. 异步操作与中间件
7. TanStack Query v5:服务端状态
8. useQuery:数据获取与缓存
9. useMutation:数据变更
10. Zustand + TanStack Query实战

1. 客户端状态 vs 服务端状态

状态管理像一个餐厅的订单系统——服务员手边的便签纸记录"5号桌加一份辣椒酱",这是客户端状态:临时、本地、不需要和厨房同步。厨房的出票系统记录"订单#23正在炒、订单#24排队中",这是服务端状态:共享、有生命周期、需要持续同步。

如果把这两种状态混在一起管理,就像让服务员去厨房查菜做没做好——能做,但每次都要跑一趟,效率极低。

维度 客户端状态 服务端状态
数据来源 用户操作产生 后端 API 返回
所有权 前端独占 后端是真相源
更新方式 用户操作触发 需重新请求同步
典型场景 主题、侧边栏开关、表单草稿 用户列表、文章详情、搜索结果
推荐工具 Zustand TanStack Query
💡 小贴士
判断用哪个工具的黄金法则:问自己"这个数据如果后端更新了,前端需要知道吗?"——如果"是",用 TanStack Query;如果"否"(纯前端数据),用 Zustand。

2. React内置状态的瓶颈

React 提供了三种内置状态方案:useState、useReducer、useContext。小项目完全够用,但组件树变深后出现两个瓶颈:

瓶颈一:Props 逐层传递

Props Drilling 问题
// 用户信息从顶层传到底层,中间组件不使用但要转发
<App>    // 持有 user 状态
  <Layout user={user}> // 仅转发,不使用
    <Sidebar user={user}> // 又转发一层
      <UserProfile user={user}> // 终于用到了
    </Sidebar>
  </Layout>
</App>

瓶颈二:Context 全局重渲染

useContext 能解决 props 传递,但有一个硬伤:Context 值变化时,所有消费该 Context 的组件全部重渲染——哪怕它只用其中一个字段。一个购物车状态包含 50 件商品,改了某件商品的数量,整个用户列表也会重渲染。

Zustand 和 TanStack Query 分别解决这两个问题:Zustand 用选择器实现精确订阅(只订阅用到的字段),TanStack Query 把服务端状态完全从组件树中抽离出来。

3. Zustand 5.0:轻量全局状态

Zustand(德语"状态")是 Poimandres 团队推出的轻量状态库,API 极简——一个 create 函数搞定一切。当前最新版本 5.0.15(2026年8月)。相比 Redux 的 action/reducer/middleware 三件套,Zustand 没有模板代码。

安装:

bash
npm install zustand
# 零依赖,包体积 ~1KB (gzipped)
维度 Zustand Redux Toolkit
包体积 ~1KB ~17KB
模板代码 几乎为零 需 slice + reducer
TypeScript 原生支持 需额外配置
学习曲线 10 分钟 数小时

4. 创建第一个Store

Store 是 Zustand 的核心——一个包含状态和操作函数的对象,用 create 创建:

src/store/cart.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { create } from 'zustand'
interface CartState {
  items: string[]
  addItem: (item: string) => void
  removeItem: (item: string) => void
  clear: () => void
}
export const useCartStore = create<CartState>((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  removeItem: (item) => set((state) => ({
    items: state.items.filter((i) => i !== item)
  })),
  clear: () => set({ items: [] })
}))

核心概念:

5. 选择性订阅与性能优化

Zustand 最强的特性是选择器——只订阅 Store 中用到的字段,其他字段变化不触发重渲染:

src/components/CartBadge.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
import { useCartStore } from '../store/cart'
function CartBadge() {
  // 只订阅 items.length,items 内容变化才重渲染
  const count = useCartStore((s) => s.items.length)
  const clear = useCartStore((s) => s.clear)
  return (
    <button onClick={clear}>
      购物车&npsp;({count})
    </button>
  )
}

useCartStore((s) => s.items.length) 只订阅 items.length。如果 Store 中增加一个 isOpen 字段并修改它,CartBadge 不会重渲染——因为它不依赖 isOpen。

⚠️ 常见错误:返回新对象导致无限重渲染

错误写法:const { items, clear } = useCartStore((s) => ({ items: s.items, clear: s.clear }))

原因:选择器每次调用都返回一个新对象字面量,Zustand 默认用 Object.is 比较,新对象永远不等于旧对象,导致无限重渲染。解决方法:分别订阅每个字段(如上例),或使用 useShallow 浅比较。

6. 异步操作与中间件

Zustand 的 set 支持异步——直接在 action 中调用 fetch 后 set 结果即可,无需 Redux Thunk 那样的中间件包装:

src/store/userStore.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { create } from 'zustand'
interface UserState {
  profile: { name: string } | null
  loading: boolean
  fetchProfile: () => Promise<void>
}
export const useUserStore = create<UserState>((set) => ({
  profile: null,
  loading: false,
  fetchProfile: async () => {
    set({ loading: true })
    // 后续用 TanStack Query 替代此模式

虽然 Zustand 能处理异步数据,但API 数据缓存、重试、过期验证这些能力不该用 Zustand 手动实现——这正是 TanStack Query 的领域。

💡 小贴士
Zustand 中间件用 persist 可以把状态自动存入 localStorage,实现页面刷新后状态不丢失:create(persist((set) => ({...}), { name: 'cart' }))。适合主题、语言、购物车等需要持久化的客户端状态。

7. TanStack Query v5:服务端状态

TanStack Query(原名 React Query)是服务端状态管理的标准答案。当前最新版本 v5.102.x(2026年8月),每周 npm 下载量超过 1800 万次。它处理了服务端状态的所有脏活:缓存、去重、后台刷新、过期管理、乐观更新。

安装:

bash
npm install @tanstack/react-query
# v5 最低要求 React 18

在应用根组件包裹 QueryClientProvider:

src/app/providers.tsx
1
2
3
4
5
6
7
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
function Providers({ children }) {
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}

TanStack Query v5 的关键变化:status: 'loading' 改为 status: 'pending',isLoading 改为 isPending,最低要求 React 18。

8. useQuery:数据获取与缓存

useQuery 接收一个查询键和查询函数,自动管理加载、错误、缓存状态:

src/hooks/useProducts.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
import { useQuery } from '@tanstack/react-query'
function useProducts() {
  return useQuery({
    queryKey: ['products'],  // 缓存键
    queryFn: async () => {
      const res = await fetch('/api/products')
      if (!res.ok) throw new Error('fetch failed')
      return res.json()
    },
    staleTime: 60000  // 1 分钟内不重新请求
  })
}

组件中使用:

src/components/ProductList.tsx
function ProductList() {
  const { data, isPending, error } = useProducts()
  if (isPending) return <p>加载中...</p>
  if (error) return <p>出错了</p>
  return data.map((p) => <div key={p.id}>{p.name}</div>)
}

TanStack Query 自动处理的行为:

9. useMutation:数据变更

useMutation 处理 POST/PUT/DELETE 等变更操作。变更成功后通过 invalidateQueries 让相关缓存失效,触发自动重新获取:

src/hooks/useAddProduct.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { useMutation, useQueryClient } from '@tanstack/react-query'
function useAddProduct() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: async (newProduct) => {
      const res = await fetch('/api/products', {
        method: 'POST',
        body: JSON.stringify(newProduct)
      })
      return res.json()
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] })
    }
  })
}

流程:组件调用 mutate(newProduct) → POST 请求发出 → 成功后 invalidateQueries 让 ['products'] 缓存失效 → useProducts 自动重新请求 → 列表更新。全程无需手动管理状态。

10. Zustand + TanStack Query 实战

典型项目同时使用两者,各管各的领域:

状态分工
// Zustand 管:UI 状态 + 用户偏好
useUIStore     // theme, sidebarOpen, selectedTab
useAuthStore   // token, isAuthenticated(纯前端标记)
// TanStack Query 管:所有 API 数据
useProducts()    // GET /api/products → 缓存 + 自动刷新
useUserProfile() // GET /api/users/me → 缓存 + 后台同步
useAddProduct() // POST /api/products → 成功后刷新列表
⚠️ 常见错误:用 Zustand 管理 API 数据

错误模式:在 Zustand action 中 fetch 用户列表并存入 store,手动维护 loading/error/lastFetched 状态。

问题:缺少缓存失效、后台刷新、请求去重、乐观更新——这些都要手写,容易出 bug。正确做法:API 数据全部用 TanStack Query 的 useQuery,Zustand 只管"用户切不切换暗色模式"这类纯前端状态。

分级练习

基础(理解概念)
1. 用 Zustand 创建一个 useThemeStore,包含 theme: 'light' | 'dark' 和 toggle() 方法,在两个组件中分别读取和修改。
2. 用 useQuery 获取 /api/users 数据,展示加载中/错误/成功三种状态。
进阶(实际应用)
3. 用 persist 中间件让 useThemeStore 持久化到 localStorage,刷新页面后主题不丢失。
4. 用 useMutation 实现删除用户功能,成功后自动刷新用户列表缓存。
挑战(综合实战)
5. 搭建一个电商页面:用 Zustand 管理购物车状态(添加/删除/清空,persist 持久化),用 TanStack Query 管理商品列表(useQuery 缓存 + staleTime 1 分钟)和下单操作(useMutation + invalidateQueries 刷新)。验证:刷新页面后购物车数据保留,商品列表从缓存秒显后后台刷新。
📌 知识回顾
客户端状态 服务端状态 Zustand 5.0.15 create() 选择器订阅 persist中间件 TanStack Query v5.102 useQuery useMutation invalidateQueries staleTime Props Drilling
下一篇预告
26 跨平台起步:Flutter 环境搭建与 Dart 入门
前端深化阶段结束,进入阶段四:跨平台 App 开发。从 Dart 语言基础到 Flutter 环境配置,用一套代码覆盖 Android 和 iOS——先在 macOS 上搭建开发环境,写第一个 Widget。