React 状态管理策略:从本地 State 到全局 Store
一个 React 项目跑久了,最容易变乱的地方往往不是组件树,而是「状态到底在哪」。
弄清楚几类状态的边界,可以少走很多弯路。
状态的几种「归属」
可以先把常见状态粗分成几类:
- 局部 UI 状态:Modal 是否打开、当前 Tab、输入框内容等;
- 跨组件共享状态:当前登录用户、主题、权限信息等;
- 服务器数据:列表、详情、配置,通常来自接口;
- URL 状态:路由参数、查询参数,能被复制粘贴的那部分。
不同类别,适合放的位置是不一样的。
一个比较实用的判断方式:这块状态是「纯 UI 行为」,还是「业务数据」,是「局部感知」,还是「全局感知」。
本地 State:能局部就不要全局
局部 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:负责「一片区域」的共享
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 的性能问题
Context 有个常见坑:Provider 的 value 一变,所有使用该 Context 的子组件都会重新渲染。
缓解方式包括:
- 拆分 Context,把变化频繁的和不怎么变的分开;
- 把 value 拆成多个字段,按需分发;
- 在消费端使用
memo、useMemo等手段减小影响。
简单说,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 状态:路由是「可分享的状态」
凡是希望「一条链接发出去,别人打开是同一视角」的东西,适合放进 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?
在很多项目里,全局 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 用在真有「全局业务状态」和「复杂更新逻辑」的场景里,最好先画一张状态拓扑图,再决定要不要引入。