7AA7 博客

React 状态管理演进:从 Redux 到原子化

NullZone UID:127
2026-09-13 09:05
60 4 2

状态管理方案的更替,主线其实只有一条:订阅粒度从粗到细。每一次范式变化都在解决”一个状态变化,到底要重渲染多少个组件”。

单向数据流与可预测性

React 的基本模型是 UI = f(state)。状态存在组件树里,自上而下流动;用户事件从下往上触发状态更新,再重新渲染。这条单向路径带来的好处是可预测性:给定同样的状态树,渲染结果一定相同。

问题出在”状态放哪”。局部状态(useState)最自然,但一旦多个远处组件需要读同一份数据,就得把它提到共同祖先,于是出现经典的 props 层层透传。

Redux 的三原则与样板代码

Redux 给出的答案是三原则:单一数据源、状态只读、用纯函数(reducer)完成修改。

function todosReducer(state = [], action) {
  switch (action.type) {
    case 'add':
      return [...state, action.payload];
    case 'toggle':
      return state.map(t => t.id === action.payload ? { ...t, done: !t.done } : t);
    default:
      return state;
  }
}

不可变更新配合引用比较,让”是否需要重渲染”变成一次 === 判断,这是整个体系能快起来的基础。

代价是样板代码。一个功能要写 action type、action creator、reducer 分支、mapStateToProps、connect 包裹。真正的痛点不是文件多,而是认知跳转:想搞清楚一个按钮点了会发生什么,要在五个文件之间来回跳。Redux Toolkit 用 createSlice 把 action 与 reducer 合并,显著缓解了这一点,但那套”一切状态都进 store”的心智负担仍在。

const todoSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    add: (state, action) => { state.push(action.payload); }, // 内部用 Immer 做可变写法
  },
});

Immer 让”可变写法产生不可变结果”成为可能,写起来舒服,代价是每个更新点都会走一遍代理开销,超大的状态树上要留意。

Context 的性能陷阱

Context 是最容易被误用的方案。它的语义是”依赖注入”,不是”状态管理”。

const CountCtx = createContext(null);

function Provider({ children }) {
  const [count, setCount] = useState(0);
  const [user, setUser] = useState(null);
  return <CountCtx.Provider value={{ count, setCount, user }}>{children}</CountCtx.Provider>;
}

上面这段代码有个隐蔽问题:value 每次渲染都是新对象。只要 count 或 user 任意一个变化,所有 useContext(CountCtx) 的消费者全部重渲染——哪怕某个组件只用到了 user。Context 没有选择器机制,Provider 的 value 引用变了,下游全体更新,无法拦截。

缓解方式有三:

  1. 拆分 Context:按更新频率分组,把高频变化的状态单独放一个 Provider;
  2. useMemo 稳定 value:让 value 引用只在真正变化时才变;
  3. React.memo 包一层中间层:把 Provider 的 children 通过 props 传进去,children 引用不变时子树不重渲染。
function Provider({ children }) {
  const [count, setCount] = useState(0);
  const value = useMemo(() => ({ count, setCount }), [count]);
  return <CountCtx.Provider value={value}>{children}</CountCtx.Provider>;
}

但这些都是补丁。Context 天生不支持”只订阅其中一部分”。

不可变更新与结构共享

不可变更新常被误解为”每次都深拷贝”。实际上正确做法是结构共享:只复制从根到被修改节点的路径,其余子树保持原引用。

// 只替换了一条 todo,其他 todo 引用不变
const next = { ...state, list: state.list.map(...) };

这让 React.memo 的浅比较能生效:没变的那部分 props 引用相同,组件直接跳过。Immer 正是自动完成这件事——它追踪哪些节点被”写入”,只复制这些路径。

要注意的边界:你如果在 reducer 里不小心写出了 state.list.sort()(原地修改),结构共享会失效——引用没变但内容变了,界面不更新。这类 bug 排查起来很费时间。

原子化与信号式:细粒度订阅

原子化方案(如 Jotai)把状态拆成一个个独立的小单元(atom),组件声明自己用了哪些 atom,只有这些 atom 变化时对应组件才更新。

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

function Display() {
  const [count] = useAtom(countAtom);
  return <span>{count}</span>; // 只有 countAtom 变才重渲染
}

订阅粒度下沉到 atom 级别,天然解决了 Context 的”全体更新”问题,也不需要写选择器或 memo。派生 atom 还能自动做记忆化——依赖没变就不重算。

信号式(signals)走得更远:它绕过 React 的渲染流程,在状态变化时直接更新对应的 DOM 节点或最小组件单元。代价是与 React 并发特性的协作要格外小心,用 useSyncExternalStore 这类官方支持的桥接方式接入才稳妥。

选择这类方案时要清楚一个前提:它们依赖订阅关系在渲染时建立。所以条件分支里动态订阅、大列表里几千个 atom 的订阅管理,都要额外设计。

外部 store 与 useSyncExternalStore

自己写订阅式 store 时,最隐蔽的 bug 是”撕裂”(tearing):并发渲染下渲染过程中 store 被外部改动,同一个界面里不同组件读到了不同版本的值。

官方给出的接法是 useSyncExternalStore,它在渲染时同步读取,并在 store 变化后强制重新同步:

const store = {
  listeners: new Set(),
  value: 0,
  set(v) { this.value = v; this.listeners.forEach(l => l()); },
  subscribe(l) { this.listeners.add(l); return () => this.listeners.delete(l); },
  get() { return this.value; },
};

function useStore() {
  return useSyncExternalStore(store.subscribe, store.get);
}

两个约定必须遵守:getSnapshot 必须是纯函数且值没变时返回同样的引用,否则会陷入无限重渲染;subscribe 要返回取消订阅的函数,且这两个函数引用要稳定(否则每次渲染重新订阅)。

这也是判断第三方状态库是否适配并发渲染的硬标准——它有没有用这个 API 接入。用了就安全,没用就可能在 startTransition 或 Suspense 场景下出问题。

并发特性对状态库的新要求

React 18 之后渲染可以被打断、重启、丢弃。这对状态管理提出了额外约束:

  • 渲染期间不要修改外部状态。render 必须是纯的,写入 store 的副作用只能放在 effect 或事件处理里。
  • 更新要保持批次一致。startTransition 标记的是”这个更新可以延后”,如果状态库自己绕过了批处理,transition 就失去意义。
  • Suspense 与竞态。组件挂起时状态可能已经改了一半,恢复后要能读到一致的数据。异步派生的状态需要显式的取消或过期标记,否则会看到闪烁。

一个实用的判断标准:状态更新能否被 useTransition 包裹而不出错、不撕裂。做不到的话,在高并发交互的页面上迟早会出现界面短暂不一致。

什么时候根本不需要状态库

很多项目装了状态库,实际需要的只是下面三件事之一:

  • 服务端数据缓存:这类数据本质是”远端数据的本地副本”,重点在缓存失效、去重、重新拉取、乐观更新。用专门的数据请求库(如 TanStack Query)比塞进 Redux 合适得多,因为它处理的是生命周期问题,不是共享问题。
  • 跨层级局部状态:页面级的筛选条件、展开状态,用 Context + useReducer 足够。组件树不深的话,直接提升状态 + props 传递最简单。
  • URL 状态:分页、筛选、排序这些状态应该活在 URL 里,刷新和分享链接能保持。硬塞进 store 反而丢失了可分享性。
// URL 作为状态源,天然支持前进后退和分享
const [params, setParams] = useSearchParams();
const page = Number(params.get('page') ?? 1);

判断标准可以这样定:状态是否需要被多个互不相邻的组件读写?是否需要跨页面存活?是否需要时间旅行/审计?三个都答”是”才认真考虑成熟的状态库;否则局部状态加提升往往更轻。

一个务实的分层:服务端数据交给请求库,URL 状态交给路由,UI 局部状态留在组件,真正需要全局共享的少量状态(如主题、登录态、购物车)用 atom 或 store。混着用不丢人,硬把所有东西塞进一个容器才是后期维护的噩梦。

1.9k浏览量
60赞
4评论
4参与用户
12 分钟阅读时间

评论(4)

  • Kael · 2026-09-13 11:16

    已经转给组里新人了,省得我一遍遍讲。

    回复
  • 沈让 · 2026-09-13 20:27

    不太懂为什么要这么绕,有没有更直接的办法?

    回复
  • 江眠 · 2026-09-14 05:19

    我这边环境跟他写的不太一样,改了两个配置才生效,供参考。

    回复
  • Vector 陈 · 2026-09-14 14:36

    想抬个杠:这个前提假设在真实业务里很少成立。

    回复