REACT

别急着上状态管理:
先分清 React
状态的四层边界

不是所有共享状态都需要一个全局 Store。先问清楚:它属于组件、页面、URL,还是服务器?

很多 React 项目的状态管理问题,并不是“状态太多了”,而是“状态住错了地方”。一个只影响下拉框开关的布尔值被放进全局 Store,一份服务端数据又在多个组件里各自复制,最后所有同步逻辑都变成 useEffect。表面上用了状态管理,实际上只是把混乱集中到了更大的容器里。

在决定使用 Context、Zustand、Redux 或任何方案之前,我会先给状态划四层边界。边界比工具更重要,因为工具可以替换,混乱的依赖关系很难。

第一层:组件内部的瞬态状态

输入框的临时值、下拉菜单是否展开、拖拽过程中的坐标、当前悬停的选项,都属于组件内部状态。它们不需要其他人知道,生命周期通常也很短。

disclosure.tsx
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 驱动。

search-page.tsx
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,或框架自带的数据层。它们关心的核心不是“把数据放哪里”,而是数据是否新鲜、何时重新验证、缓存键如何划分、失效范围是什么。

一个简化后的使用形态大致是这样:

profile.tsx
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 之外的世界同步的工具,不应该承担状态之间互相搬运的职责。

一个自检问题

如果某个状态删掉后,系统仍然可以通过其他状态计算得到它,那么它很有可能只是一个派生值。派生值不拥有生命周期,也不应该被手动同步。

我会怎样做选择

  1. 服务端拥有真相吗?是,就交给请求缓存层,按服务端数据管理。
  2. 用户希望分享、刷新保留或后退还原吗?是,就放进 URL。
  3. 只有一棵子树需要共享吗?是,提升到最近公共祖先,或在该范围内使用 Context。
  4. 只有当前组件关心吗?是,就留在组件内部。

如果这四层都处理清楚后,仍然有跨页面、跨领域、需要事务化更新的纯客户端状态,那时再引入全局状态工具。此时你知道它为什么存在,也能明确它的边界。

复杂交互用状态机,而不是更多布尔值

当同一个区域同时有 isLoading、isError、isEmpty、isEditing 等多个布尔值时,非法组合会自然出现。例如“加载中”和“显示错误”同时为真。与其增加更多判断,不如用可辨识联合描述状态:

editor-state.ts
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 时,先试着画出状态的边界。画清楚之后,你也许会发现根本不需要它;如果确实需要,至少这次它会待在该待的位置。

沈健健

前端工程师,关注界面工程、性能与设计系统。相信好的技术判断,最终都会反映在用户不必思考的细节里。