前端状态管理全景:从 useState 到服务端状态的选型决策

引言 状态管理是前端架构中最容易被低估、也最容易演变成技术债的领域。useState 解决的是组件局部状态,Context 解决的是跨层级传递,但当共享状态开始跨越模块、页面甚至服务端边界时,方案的取舍就变得复杂。本文系统梳理从 React 内置方案到 Redux/Zustand/信号、再到 TanStack Query 的完整图谱,并给出可落地的选型决策树。

引言

状态管理是前端架构中最容易被低估、也最容易演变成技术债的领域。useState 解决的是组件局部状态,Context 解决的是跨层级传递,但当共享状态开始跨越模块、页面甚至服务端边界时,方案的取舍就变得复杂。本文系统梳理从 React 内置方案到 Redux/Zustand/信号、再到 TanStack Query 的完整图谱,并给出可落地的选型决策树。文章中涉及的全部 API 均为真实存在的稳定 API,包括 React 18 的 useSyncExternalStore、Redux Toolkit 的 createSlice 与 configureStore、Zustand 的 create 与 persist middleware、TanStack Query 的 useQuery 等。


一、useState 与组件局部状态:从 Props 透传到状态提升

1.1 局部状态的最小单位

任何组件状态管理方案的起点都是组件内部的局部状态。React 的 useState 返回一个状态值和对应的更新函数,更新函数触发组件重新渲染:

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>当前计数:{count}</p>
      <button onClick={() => setCount((c) => c + 1)}>加一</button>
    </div>
  );
}

当多个兄弟组件需要共享同一份状态时,React 官方的推荐做法是状态提升(Lifting State Up)——把状态移动到最近的公共父组件中,再通过 Props 传递。这一模式解决了基本问题,但在组件树层级较深时会引发 Props 逐层透传(Prop Drilling)的样板代码问题。

1.2 为什么局部状态会失控

局部状态只有在三个条件同时满足时才是正确的选择:状态只被当前组件及其直接子树消费、不需要跨模块共享、不需要持久化。一旦状态开始被多个无关组件读写,或需要在路由切换后保留,把状态锁死在组件内部就会产生数据不一致。

下表概括了不同作用域的状态应该使用哪种机制:

状态作用域推荐机制反模式
单个组件内部useState / useReducer全局 Store
组件树内部共享Context + useReducer每个组件各存一份
跨页面/跨模块共享Zustand / Redux层层 Props 透传
服务端数据缓存TanStack Query / SWR塞进全局 Store

二、Context 的局限与 useReducer 的补偿

2.1 Context 的渲染陷阱

Context 解决了 Props 透传,但它有一个被广泛误解的性能陷阱:当 Provider 的 value 变化时,所有消费该 Context 的组件都会重新渲染,无论它们是否只关心 value 中的一部分。经典的对策是把 value 拆成多个独立 Context,或用 useMemo 包裹 value:

import { createContext, useMemo, useState } from 'react';

export const ThemeContext = createContext({ theme: 'light', setTheme: () => {} });

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  // 没有 useMemo 时,每次渲染都会生成新对象,导致所有消费方重渲染
  const value = useMemo(() => ({ theme, setTheme }), [theme]);

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

2.2 useReducer:集中变更逻辑

当组件内状态存在多条变更路径、且变更规则相互依赖时,useReducer 比多个 useState 更可维护。它把"状态如何变化"集中到 reducer 纯函数中,便于测试和调试:

import { useReducer } from 'react';

function reducer(state, action) {
  switch (action.type) {
    case 'increment': return { count: state.count + 1 };
    case 'decrement': return { count: state.count - 1 };
    case 'reset': return { count: 0 };
    default: return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, { count: 0 });
  return (
    <>
      <span>{state.count}</span>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'reset' })}>重置</button>
    </>
  );
}

不过 useReducer 只解决"变更逻辑组织",不解决"跨组件共享"。Context + useReducer 的组合适合中大型组件树内的共享,但仍然无法规避整树重渲染问题。


三、Redux 生态:不可变更新与 DevTools

3.1 Redux Toolkit 的现代形态

Redux 是历史最悠久的全局状态方案之一。现代 Redux 实践以 Redux Toolkit(RTK) 为标准写法,它内置了 Immer 支持,让不可变更新可以写成可变的赋值语法:

import { createSlice, configureStore } from '@reduxjs/toolkit';

const cartSlice = createSlice({
  name: 'cart',
  initialState: { items: [] },
  reducers: {
    addItem(state, action) {
      // Immer 允许"看起来像可变"的写法,内部仍生成不可变状态
      state.items.push(action.payload);
    },
    removeItem(state, action) {
      state.items = state.items.filter((item) => item.id !== action.payload.id);
    },
  },
});

export const { addItem, removeItem } = cartSlice.actions;

export const store = configureStore({
  reducer: { cart: cartSlice.reducer },
  // 默认已启用 redux-devtools、redux-thunk,无需额外配置
});

3.2 中间件与持久化

Redux 的中间件机制让副作用处理、日志记录、持久化都可以插入到 dispatch 链路中。redux-persist 是常用的持久化方案,它把 store 的一部分白名单同步到 localStorage:

import { persistStore, persistReducer } from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import { combineReducers } from '@reduxjs/toolkit';

const rootReducer = combineReducers({ cart: cartSlice.reducer });

const persistedReducer = persistReducer(
  { key: 'root', storage, whitelist: ['cart'] },
  rootReducer
);

export const store = configureStore({ reducer: persistedReducer });
export const persistor = persistStore(store);

Redux 的优势是可预测性与生态成熟:单一数据源、纯 reducer、时间旅行调试。代价是样板代码多、需要显式组织 action/reducer/selector,对于中小型项目往往过重。


四、原子状态:Zustand / Jotai / Recoil 对比

4.1 Zustand:最小化全局 Store

Zustand 提供一种极简的全局 Store 模型。它内部通过 useSyncExternalStore 连接 React 渲染系统,外部通过 getState() 可以在非 React 环境中读取,这在路由守卫、事件处理器中非常方便:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export const useUserStore = create(
  persist(
    (set, get) => ({
      user: null,
      login: (user) => set({ user }),
      logout: () => set({ user: null }),
      isLoggedIn: () => get().user !== null,
    }),
    { name: 'user-storage' } // 自动持久化到 localStorage
  )
);

组件中可以用 selector 精确订阅,避免无关状态更新触发重渲染:

const username = useUserStore((state) => state.user?.name);

4.2 Jotai 与 Recoil:原子模型

Jotai 与 Recoil 都采用原子(Atom)模型:状态被拆分为最小单元,通过派生关系组合。Jotai 的 API 更轻量,无需 Provider 包裹即可使用(尽管生产环境仍推荐挂 Provider 以支持测试与持久化):

import { atom, useAtom } from 'jotai';

const countAtom = atom(0);
const doubleAtom = atom((get) => get(countAtom) * 2);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const [double] = useAtom(doubleAtom);
  return (
    <div>
      <span>{count} / 两倍:{double}</span>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
    </div>
  );
}

4.3 三方案对比矩阵

维度ZustandJotaiRecoil
模型单一 Store + selector原子 + 派生原子 + selector
Provider 要求不需要可选必须
SSR 支持内置内置需额外处理
中间件/持久化persist/devtools middleware社区插件实验性 persist
学习曲线低低中
React 内部机制useSyncExternalStoreuseSyncExternalStore内部订阅系统
适用场景中型全局状态细粒度原子状态大型复杂派生状态

三者都是真实且活跃的库。选择时不必迷信流行度:如果你的状态大多是"一份全局用户信息 + 少量标志位",Zustand 足够;如果状态之间存在大量互相派生的计算,Jotai/Recoil 的原子模型更契合。


五、信号(Signals):Preact Signals 与响应式范式回归

5.1 信号的基本用法

信号(Signals)是近年回归的热门范式。Solid.js 以信号作为核心,Preact 通过 @preact/signals 让 React 也能使用信号。信号的核心特点是值本身是响应式容器,读写时自动建立依赖图,因此更新可以精确到消费节点:

import { signal, computed, effect } from '@preact/signals';

const count = signal(0);
const double = computed(() => count.value * 2);

// 任何读取 count.value 的 effect 都会自动订阅
effect(() => {
  console.log(`count 变化为:${count.value},双倍为:${double.value}`);
});

count.value += 1; // 自动触发上述 effect

在 React 组件中,信号通过 useSignals 或直接读取 .value(配合官方 React adapter)完成状态读取,读写时不会触发整棵组件树的重渲染——这是它与 Context 的本质区别。

5.2 信号与现有方案的互补关系

需要强调的是,信号并不是要取代 React 的渲染模型。React 官方对信号持开放态度,社区也出现了 useSyncExternalStore 作为连接信号与 React 的通用桥梁。信号更适合高频更新的共享值(如动画进度、鼠标位置、实时仪表盘数据),因为这些场景下每次更新触发全树 diff 的代价过高。

特征useState信号Context
更新粒度组件级值级消费组件级
细粒度订阅需 useMemo/拆分内置不支持
组件外读取不支持支持不支持
与 React 并发模式兼容需 adapter兼容

六、服务端状态:TanStack Query 与 SWR

6.1 服务端状态是另一类问题

前端状态可以粗分为两类:客户端状态(用户偏好、表单草稿)与服务端状态(来自 API 的数据)。服务端状态有缓存、失效、重试、竞态等复杂语义,把它塞进全局 Store 往往导致大量重复代码。TanStack Query 把服务端状态提升为一级概念,useQuery 负责数据获取、缓存、后台刷新:

import { useQuery } from '@tanstack/react-query';

function UserProfile({ userId }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () =>
      fetch(`/api/users/${userId}`).then((res) => res.json()),
    staleTime: 5 * 60 * 1000, // 5 分钟内视为"新鲜",不重新请求
    gcTime: 30 * 60 * 1000,   // 缓存保留 30 分钟
  });

  if (isLoading) return <div>加载中…</div>;
  if (error) return <div>出错了:{error.message}</div>;
  return <div>{data.name}</div>;
}

6.2 写操作与失效

写操作通过 useMutation 表达,成功后可以用 invalidateQueries 让相关查询标记为过期并自动刷新:

import { useMutation, useQueryClient } from '@tanstack/react-query';

function UpdateName({ userId }) {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: (name) =>
      fetch(`/api/users/${userId}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ name }),
      }),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
  });

  return <button onClick={() => mutation.mutate('新名字')}>改名</button>;
}

SWR 是同一赛道的轻量选择,核心理念是"stale-while-revalidate":先返回缓存数据,再后台重新验证。下表对比两个库的取舍:

特性TanStack QuerySWR
缓存失效机制queryKey + invalidateQueriesmutate + revalidate
依赖收集显式 queryKey显式 key
离线/重连恢复支持支持
数据偏平/无限加载useInfiniteQueryuseSWRInfinite
与 UI 框架耦合React/Vue/Svelte 多端React 为主

服务端状态是全局 Store 的最大误用场景。团队经常把接口返回的列表复制进 Redux/Zustand,然后手写 loading/error/refresh 三件套——这些 TanStack Query 已经内置。实践中推荐的边界是:全局 Store 只放客户端共享状态,服务端数据一律交给查询缓存。


七、路由状态与 URL:把状态同步到浏览器地址栏

7.1 用 useSearchParams 表达页面状态

筛选条件、分页页码、搜索关键词这类状态天然适合放在 URL 中——刷新不丢失、可分享、可被浏览器前进后退。React Router 的 useSearchParams 让 URL 成为状态源:

import { useSearchParams } from 'react-router-dom';

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();
  const category = searchParams.get('category') ?? 'all';
  const page = Number(searchParams.get('page') ?? '1');

  const setCategory = (next) => {
    // 更新 URL 查询参数,触发对应状态变化
    setSearchParams((prev) => ({ ...Object.fromEntries(prev), category: next, page: '1' }));
  };

  return (
    <div>
      <button onClick={() => setCategory('electronics')}>电子</button>
      <p>当前分类:{category},页码:{page}</p>
    </div>
  );
}

7.2 URL 状态 vs 全局 Store

判断一个状态是否应该进 URL,可以问三个问题:刷新后需要保留吗?需要分享给其他人吗?浏览器前进后退需要支持吗?只要有一个为"是",就优先考虑 URL 状态。

判断维度进 URL进全局 Store
刷新保留天然满足需持久化中间件
可分享/书签天然满足不支持
前后退语义天然满足需手动同步
二进制/大对象不适合适合

八、状态持久化与调试

8.1 持久化的分层设计

持久化并非"把整个 store 存进 localStorage"这么简单。需要区分:哪些状态需要跨会话保留(登录态、主题、购物车)、哪些状态只需要跨刷新保留(表单草稿)、哪些状态禁止落盘(敏感信息)。Zustand 的 persist middleware 支持按 key 白名单:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

export const usePreferencesStore = create(
  persist(
    (set) => ({
      theme: 'light',
      locale: 'zh-CN',
      setTheme: (theme) => set({ theme }),
      setLocale: (locale) => set({ locale }),
    }),
    {
      name: 'preferences',
      partialize: (state) => ({ theme: state.theme, locale: state.locale }),
    }
  )
);

partialize 只持久化指定字段,避免把非序列化内容(函数、Date 实例)写入存储。

一条务实原则:持久化的是「用户可感知的偏好」,不是「全部应用状态」。登录态、主题、草稿值得落盘,临时 UI 标志位放进 sessionStorage 或干脆不持久化。

8.2 可调试性:DevTools 与时间旅行

全局状态方案的核心卖点之一是可调试性。Redux DevTools 提供时间旅行(time travel):逐步回放 action,观察每一步状态。Zustand 通过 devtools middleware 接入同一套 DevTools 协议:

import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

export const useAppStore = create(
  devtools((set) => ({
    count: 0,
    increment: () => set((state) => ({ count: state.count + 1 })),
  }))
);

调试的另一个实用手段是状态快照:在 action 处理前后记录 JSON.stringify 的状态摘要,配合日志检索定位问题。很多团队把这做成一个全局 logger middleware,一劳永逸。


九、SSR 下的状态与 useSyncExternalStore

9.1 为什么 SSR 会破坏全局状态

服务端渲染时,每个请求都应该有独立的状态容器。如果全局 Store 是模块级单例,那么第一个请求写入的数据会被第二个请求读到——这就是经典的跨请求状态污染。正确做法是在请求周期内创建新的 store 实例:

// 不再导出单例,而是导出工厂函数
import { createStore } from 'zustand';

export const createAppStore = () =>
  createStore((set) => ({
    user: null,
    setUser: (user) => set({ user }),
  }));

在 Next.js 中,App Router 推荐使用 React 的 cache() 包裹工厂函数,确保单次请求内复用同一个 store 实例,同时避免请求间污染。

9.2 useSyncExternalStore:React 官方订阅原语

React 18 提供的 useSyncExternalStore 是连接外部数据源(Zustand、Redux、DOM API)与 React 渲染系统的官方桥。它要求数据源提供三个函数:subscribe、getSnapshot 和可选的 getServerSnapshot(用于 SSR 水合时避免客户端/服务端快照不一致):

import { useSyncExternalStore } from 'react';

const store = {
  state: { count: 0 },
  listeners: new Set(),
  subscribe(listener) {
    store.listeners.add(listener);
    return () => store.listeners.delete(listener);
  },
  getSnapshot() {
    return store.state;
  },
  emit() {
    store.listeners.forEach((l) => l());
  },
};

function CounterView() {
  const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot);
  return <span>{snapshot.count}</span>;
}

getSnapshot 必须返回缓存一致的引用,否则 React 会判定"快照变化"而无限重渲染。这正是为什么第三方库内部都自行管理不变性——Zustand 在每次 set 时创建新对象,Redux 通过 Immer 保证不可变更新。理解 useSyncExternalStore,就理解了这些库如何与 React 的并发渲染安全共存。

9.3 选型决策树

综合全文,可以按以下顺序做选型判断:

问题答案选择
数据来自服务端 API?是TanStack Query / SWR
只在单页内部组件树共享?是Context + useReducer
需要刷新保留、可分享?是URL 状态(useSearchParams)
需要跨模块全局共享?是Zustand(简单)/ Redux(复杂业务与规范)
状态间大量派生计算?是Jotai / Recoil
高频更新的共享值?是信号(@preact/signals)

选型的真正难点不是库与库之间的比较,而是先识别状态属于哪一类:客户端状态、服务端状态,还是 URL 状态。类别判断对了,工具选择自然水到渠成。


结语

状态管理没有银弹,只有"状态作用域 + 数据来源"两个维度的组合判断。局部状态用 useState/useReducer,组件树共享用 Context,跨模块全局共享交给 Zustand 或 Redux,原子化派生用 Jotai/Recoil,服务端数据必须交给 TanStack Query 类方案,而 URL 是常被忽视的"最可靠的持久化"。同时,useSyncExternalStore 作为 React 官方外部状态桥,值得每个前端工程师深入理解——它是连接第三方数据源与 React 并发渲染的基石。

在架构评审时,我建议团队为每个新状态写三行"状态声明":它从哪来(服务端/客户端/URL)、它的生命周期(单页/会话/永久)、谁消费它(单组件/多组件/全局)。回答完这三个问题,80% 的选型争议都会自然消解。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「frontend」更多文章

  1. CSS 架构与样式方案:从方法论到现代 CSS 新特性
  2. 可访问性与国际化:WCAG 2.2、ARIA 与 i18n 工程实践
  3. SSR/SSG 渲染模式全景:Next.js App Router、流式渲染与岛屿架构