很多 React 项目的状态管理问题,并不是“状态太多了”,而是“状态住错了地方”。一个只影响下拉框开关的布尔值被放进全局 Store,一份服务端数据又在多个组件里各自复制,最后所有同步逻辑都变成 useEffect。表面上用了状态管理,实际上只是把混乱集中到了更大的容器里。
在决定使用 Context、Zustand、Redux 或任何方案之前,我会先给状态划四层边界。边界比工具更重要,因为工具可以替换,混乱的依赖关系很难。
第一层:组件内部的瞬态状态
输入框的临时值、下拉菜单是否展开、拖拽过程中的坐标、当前悬停的选项,都属于组件内部状态。它们不需要其他人知道,生命周期通常也很短。
function Filters() {
const [isOpen, setIsOpen] = useState(false);
return (
<section>
<button aria-expanded={isOpen} onClick={() => setIsOpen(!isOpen)}>
筛选条件
</button>
{isOpen && <FilterPanel />}
</section>
);
}
这类状态放在组件里不是“临时方案”,而是最清晰的最终方案。过早提升会让父组件知道它本不该知道的细节,也会让更多组件因为无关更新而重新渲染。
第二层:组件树内的共享状态
当两个兄弟组件需要协作时,把状态提升到最近的共同父级。例如一个筛选面板和结果列表共享“已选分类”,没必要上升到整个应用。
如果中间层级太深,可以引入 Context,但要区分两件事:Context 是依赖注入机制,不是自动的状态管理方案。把 store 放进 Context 并不会自动减少重渲染,也不会解决异步、缓存或数据一致性。
- 按领域拆分 Context,避免一个包含用户、主题、筛选、弹窗的“万能 Context”。
- 同时提供 state 与 actions 两个独立 Context,减少 action 消费者无谓更新。
- Context 的 value 要稳定,不要每次渲染新建对象却没有必要时。
如果共享关系只在页面级存在,就不需要跨路由持久化。路由切换时重置,反而能避免旧筛选条件污染新页面。
第三层:URL 中的可分享状态
分页、搜索词、排序、当前标签页,只要它影响用户看到的页面内容,并且用户希望刷新后保留、复制链接后还原、使用后退按钮回到上一步,它就应该在 URL 里。
只放在组件里
- 刷新后筛选条件消失
- 链接无法还原当前视图
- 需要自己实现历史记录
作为 URL 的派生结果
- 可以分享与收藏
- 前进后退天然可用
- 刷新与直达行为一致
关键原则是:URL 是这一层状态的唯一真相,不要再复制进本地 state,然后用 Effect 双向同步。组件直接读取 URL,用户操作时修改 URL,数据请求由 URL 驱动。
function SearchPage() {
const [params, setParams] = useSearchParams();
const query = params.get('q') ?? '';
const page = Number(params.get('page') ?? 1);
// query 和 page 变化,自然触发对应请求
const result = useSearchResults({ query, page });
function submit(nextQuery: string) {
setParams({ q: nextQuery, page: '1' });
}
return <SearchView result={result} onSubmit={submit} />;
}
第四层:服务端数据缓存
远端用户信息、文章列表、订单详情,本质上是“服务器的数据在客户端的一份缓存”。它不由前端创建,也很难由前端拥有最终真相。把它塞进普通全局状态,会带来三个问题:
- 你需要手动维护加载、错误、刷新、重试和请求去重;
- 它可能在任何时刻被服务端改变,前端无法拥有绝对权威;
- 多个页面共享时,过期与失效策略会迅速复杂化。
这类状态更适合交给专门的请求缓存工具,例如 TanStack Query、SWR,或框架自带的数据层。它们关心的核心不是“把数据放哪里”,而是数据是否新鲜、何时重新验证、缓存键如何划分、失效范围是什么。
一个简化后的使用形态大致是这样:
const profileQuery = (userId: string) => ({
queryKey: ['profile', userId],
queryFn: () => fetchProfile(userId),
staleTime: 60_000,
});
function Profile({ userId }: { userId: string }) {
const { data, isPending, error } = useQuery(profileQuery(userId));
if (isPending) return <ProfileSkeleton />;
if (error) return <ErrorState error={error} />;
return <ProfileView profile={data} />;
}
不要把服务端缓存再复制到 Zustand 或 Redux,然后又试图同步两边。你可以把“当前选中的筛选条件”放进全局状态,但最终请求结果属于缓存层;两者的生命周期完全不同。
派生状态和 Effect 的陷阱
很多状态其实不需要存在。由已有状态能够计算出来的值,应该在渲染时计算,而不是再用一个 state 保存。
同步出来的第二份真相
const [visible, setVisible] = useState([]);
useEffect(() => {
setVisible(items.filter(matches));
}, [items, keyword]);
渲染期间直接派生
const visible = items.filter(matches);
只有在计算确实昂贵、并且需要缓存时才考虑 useMemo,而不是因为“它是计算,所以必须记忆化”。同样,useEffect 更像与 React 之外的世界同步的工具,不应该承担状态之间互相搬运的职责。
如果某个状态删掉后,系统仍然可以通过其他状态计算得到它,那么它很有可能只是一个派生值。派生值不拥有生命周期,也不应该被手动同步。
我会怎样做选择
- 服务端拥有真相吗?是,就交给请求缓存层,按服务端数据管理。
- 用户希望分享、刷新保留或后退还原吗?是,就放进 URL。
- 只有一棵子树需要共享吗?是,提升到最近公共祖先,或在该范围内使用 Context。
- 只有当前组件关心吗?是,就留在组件内部。
如果这四层都处理清楚后,仍然有跨页面、跨领域、需要事务化更新的纯客户端状态,那时再引入全局状态工具。此时你知道它为什么存在,也能明确它的边界。
复杂交互用状态机,而不是更多布尔值
当同一个区域同时有 isLoading、isError、isEmpty、isEditing 等多个布尔值时,非法组合会自然出现。例如“加载中”和“显示错误”同时为真。与其增加更多判断,不如用可辨识联合描述状态:
type EditorState =
| { status: 'idle' }
| { status: 'editing'; draft: string }
| { status: 'saving'; draft: string }
| { status: 'error'; message: string }
| { status: 'saved'; id: string };
这样每个状态携带它真正需要的数据,UI 只需要根据 status 分支。复杂流程还可以进一步收敛为一个 reducer 或显式状态机,让“允许哪些操作”变成可测试的规则。
先画边界,再选工具
状态管理工具不是问题本身,错误的状态归属才是。大多数应用的复杂度,可以通过四个问题被拆小:组件局部、组件树、URL、服务端缓存。真正需要全局客户端状态的,往往比想象中少。
当你下次准备安装一个新的 Store 时,先试着画出状态的边界。画清楚之后,你也许会发现根本不需要它;如果确实需要,至少这次它会待在该待的位置。