React 状态管理策略:从本地 State 到全局 Store

一个 React 项目跑久了,最容易变乱的地方往往不是组件树,而是「状态到底在哪」。
弄清楚几类状态的边界,可以少走很多弯路。

可以先把常见状态粗分成几类:

  • 局部 UI 状态:Modal 是否打开、当前 Tab、输入框内容等;
  • 跨组件共享状态:当前登录用户、主题、权限信息等;
  • 服务器数据:列表、详情、配置,通常来自接口;
  • URL 状态:路由参数、查询参数,能被复制粘贴的那部分。

不同类别,适合放的位置是不一样的。

一个比较实用的判断方式:这块状态是「纯 UI 行为」,还是「业务数据」,是「局部感知」,还是「全局感知」

局部 UI 状态优先放在组件内部:

 1function ModalExample() {
 2  const [open, setOpen] = useState(false);
 3
 4  return (
 5    <>
 6      <button onClick={() => setOpen(true)}>打开</button>
 7      {open && (
 8        <Modal onClose={() => setOpen(false)}>
 9          内容...
10        </Modal>
11      )}
12    </>
13  );
14}

如果发现很多组件都在关心同一个局部状态,大多数时候更合理的做法是:

  • 把相关的那一块组件拉近,收拢到同一个父组件里;
  • 或者抽成一个「小范围的 Context」,而不是直接丢到全局 Store 里。

总的倾向是:能局部就局部,别上来就挂到全局。

Context 更适合作为「一种注入机制」,而不是一切状态的入口。

典型的使用场景:

  • 主题 / 语言;
  • 当前用户信息;
  • 某个子树内共享的配置;
  • 与 UI 强绑定的「局部全局状态」。
 1type Theme = 'light' | 'dark';
 2
 3const ThemeContext = createContext<Theme>('light');
 4
 5function App() {
 6  const [theme, setTheme] = useState<Theme>('light');
 7
 8  return (
 9    <ThemeContext.Provider value={theme}>
10      <Toolbar onToggleTheme={() => setTheme(t => t === 'light' ? 'dark' : 'light')} />
11    </ThemeContext.Provider>
12  );
13}
14
15function Toolbar(props: { onToggleTheme: () => void }) {
16  const theme = useContext(ThemeContext);
17  return (
18    <div className={`toolbar toolbar-${theme}`}>
19      <button onClick={props.onToggleTheme}>切换主题</button>
20    </div>
21  );
22}

Context 有个常见坑:Provider 的 value 一变,所有使用该 Context 的子组件都会重新渲染

缓解方式包括:

  • 拆分 Context,把变化频繁的和不怎么变的分开;
  • 把 value 拆成多个字段,按需分发;
  • 在消费端使用 memouseMemo 等手段减小影响。

简单说,Context 适合作为「信号广播」,不适合作为「所有数据的总线」。

服务器数据有几个明显特征:

  • 需要缓存和失效策略;
  • 需要处理并发请求、错误重试;
  • 需要和「本地乐观更新」一起工作。

这些需求用手写 useEffect + useState 可以实现,但维护成本很高,因此逐渐形成了一类专门的库,比如 React Query、SWR 等。

以 React Query 为例,通常会这样组织:

1const queryClient = new QueryClient();
2
3function App() {
4  return (
5    <QueryClientProvider client={queryClient}>
6      <Root />
7    </QueryClientProvider>
8  );
9}

在组件里声明「我要什么」:

1function TodoList() {
2  const { data, isLoading, error } = useQuery({
3    queryKey: ['todos'],
4    queryFn: () => fetch('/api/todos').then(r => r.json()),
5  });
6
7  // ...
8}

这类库帮你解决的东西通常包括:

  • 请求缓存与去重;
  • 自动重试与错误处理;
  • 失效与重新拉取;
  • 在多个组件之间共享同一份数据。

和全局 Store 相比,服务器状态更适合用专门的「远程状态库」来管,而不是放进自己写的 Redux/Zustand 里。

凡是希望「一条链接发出去,别人打开是同一视角」的东西,适合放进 URL 里:

  • 当前路由;
  • 查询条件(页码、排序、筛选);
  • 某个资源的 id。

以 React Router 为例,可以把部分状态映射到 query string 里:

 1import { useSearchParams } from 'react-router-dom';
 2
 3function UserListPage() {
 4  const [searchParams, setSearchParams] = useSearchParams();
 5  const page = Number(searchParams.get('page') || '1');
 6
 7  function goToPage(p: number) {
 8    setSearchParams({ page: String(p) });
 9  }
10
11  // ...
12}

用 URL 承载状态的好处是天然具备:

  • 可分享性(直接复制链接);
  • 可回退/前进(浏览器历史);
  • 刷新不丢失。

在设计时可以问一句:这块状态如果丢了,刷新后用户是否可以接受?
不能接受的,多半应该进 URL 或某种持久化存储。

在很多项目里,全局 Store 常常被用得过度。比较适合引入的信号包括:

  • 存在大量「跨页面、跨模块」共享的业务状态;
  • 这些状态需要比较复杂的更新逻辑(例如状态机、工作流);
  • 需要 DevTools 来回放、追踪状态变化;
  • 团队有足够的共识和规范去维护这套 Store。

常见的选择包括:

  • Redux Toolkit:结构相对规范,生态成熟;
  • Zustand:API 简洁,比较适合中小型状态需求;
  • 其他如 Jotai、Recoil 等,各有偏重场景。

无论选哪一种,都尽量遵守一个原则:全局 Store 里只放那些「确实是全局的状态」,不要把所有东西都塞进去。

在做稍微复杂一点的前端项目时,很有用的一件事是画一张「状态拓扑图」,粗略标出:

  • 哪些状态是局部 state;
  • 哪些通过 Context 向下传;
  • 哪些通过远程状态库管理服务器数据;
  • 哪些沉到全局 Store;
  • 哪些体现在 URL 里。

这张图不用太细,但可以帮团队在讨论时统一语言:
比如「这个选中的项目 id 放 URL,列表数据放 React Query,当前页的临时编辑状态放组件本地」。

  • 本地 state 适合纯 UI 行为和局部逻辑,能局部就局部。
  • Context 更适合做「一片区域的依赖注入」,不要硬当总线用,用多了要注意性能和拆分。
  • 服务器数据优先交给专门的远程状态库(React Query、SWR 等),不要全部塞进自建 Store。
  • URL 是天然适合承载「可分享状态」的地方,尤其是路由和筛选条件。
  • 全局 Store 用在真有「全局业务状态」和「复杂更新逻辑」的场景里,最好先画一张状态拓扑图,再决定要不要引入。