React 基础体系 · 第 33/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React useSyncExternalStore:外部状态、一致快照、订阅和 SSR

useSyncExternalStore 用于让 React 组件读取并订阅“React 组件状态之外”的数据源,例如:

  • 浏览器在线状态、媒体查询、窗口尺寸;
  • WebSocket、BroadcastChannel、Service Worker 推送的数据;
  • 自定义状态管理库;
  • 被多个组件或多个 React 树共享的单例状态;
  • 旧式事件系统或已有的外部缓存。

它解决的不是“如何保存一个变量”,而是一个更严格的问题:

当 React 以并发方式渲染组件时,如何让组件读取外部状态的一致快照,并在外部状态变化后正确触发重新读取?

React 19 中的 API 形式是:

const snapshot = useSyncExternalStore(
  subscribe,
  getSnapshot,
  getServerSnapshot?,
);

三个参数分别表示:

  • subscribe(listener):注册变化监听器,并返回取消订阅函数;
  • getSnapshot():读取当前客户端快照;
  • getServerSnapshot():服务端渲染和客户端 hydration 期间读取快照,可选,但使用 SSR 时应提供。

一、先区分 React 状态和外部状态

React 状态由 React 自己管理:

function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      {count}
    </button>
  );
}

调用 setCount 后,React 知道:

  1. 哪个组件拥有这份状态;
  2. 哪些组件需要重新渲染;
  3. 哪些渲染任务已经过期;
  4. 如何在提交 DOM 前重新检查状态。

外部状态则不经过 React 的状态队列:

window.addEventListener("online", () => {
  // 浏览器内部状态发生变化
});

socket.addEventListener("message", (event) => {
  // WebSocket 数据发生变化
});

cache.set("/api/user", user);

这些数据源可以在 React 组件外部更新。React 不会因为某个普通对象被修改,就自动知道哪些组件需要重新渲染。

因此,外部状态至少需要两个能力:

type Subscribe = (listener: () => void) => () => void;
type GetSnapshot<T> = () => T;

getSnapshot 负责回答:

当前外部状态是什么?

subscribe 负责回答:

外部状态变化时,应该通知谁重新读取它?

useSyncExternalStore 就是把这两个能力接入 React 的协议。


二、为什么不能只用 useEffect 订阅

一个常见但不完整的写法是:

function Component() {
  const [value, setValue] = useState(store.getValue());

  useEffect(() => {
    return store.subscribe(() => {
      setValue(store.getValue());
    });
  }, []);

  return <span>{value}</span>;
}

它在许多简单场景下可以工作,但它有一个重要缺陷:初始渲染和订阅建立之间存在窗口期

时间顺序可能是:

1. render:读取 store.getValue(),得到 A
2. React 提交组件
3. useEffect 尚未执行
4. 外部 store 变化为 B
5. useEffect 执行并开始订阅

如果 store 的订阅机制不会补发当前状态,组件可能永远错过这次变化,继续显示 A。

即使通过手动补读修复这个窗口,也仍然没有完整解决并发渲染中的一致性问题。useSyncExternalStore 让 React 在渲染和提交之间重新检查外部快照,从而避免提交一个已经过期的读取结果。


三、什么是“一致快照”

1. 快照不是随意返回的新对象

假设外部状态如下:

type State = {
  count: number;
  user: {
    name: string;
  };
};

一个快照表示某一时刻完整的状态视图:

const snapshot = {
  count: 1,
  user: {
    name: "Ada",
  },
};

getSnapshot() 可以返回原始对象,也可以返回不可变对象,但必须满足一个关键条件:

在外部状态没有变化时,多次调用 getSnapshot() 必须返回相同的、可缓存的结果。

例如,下面的实现是错误的:

function getSnapshot() {
  return {
    count: store.count,
  };
}

即使 store.count 没有变化,每次调用也会创建新对象。React 会通过对象身份判断快照是否变化,连续的新对象会让 React 认为状态一直在变化,严重时会出现:

The result of getSnapshot should be cached

正确做法是缓存状态对象:

let state = { count: 0 };

function getSnapshot() {
  return state;
}

function setCount(count: number) {
  state = { ...state, count };
}

这里每次真正更新时生成一个新对象;没有更新时,getSnapshot() 始终返回同一个对象。

2. Object.is 与快照变化

可以把 React 的判断简化为:

snapshot_next = getSnapshot()
changed = !Object.is(snapshot_previous, snapshot_next)

这不是要求所有内部字段都不可变,而是要求 getSnapshot 返回的快照具有稳定的身份语义。

对于原始值:

let online = false;

function getSnapshot() {
  return online;
}

只要 false 仍然是 false,快照就是稳定的。

对于对象:

let state = { count: 0 };

更新时应替换对象:

state = { count: 1 };

而不是原地修改:

state.count = 1; // 不推荐

原地修改会导致:

修改前 snapshot === 修改后 snapshot

如果仍返回同一个对象,React 可能认为快照没有变化,即使对象内部字段已经被修改。


四、完整的外部 Store 实现

下面实现一个最小但完整的 TypeScript store。它包含:

  • 当前状态;
  • 快照读取;
  • 订阅和取消订阅;
  • 不可变更新;
  • 变化通知。
// counter-store.ts
export type CounterState = {
  count: number;
};

export type Listener = () => void;

export class CounterStore {
  private state: CounterState;
  private readonly listeners = new Set<Listener>();

  constructor(initialState: CounterState = { count: 0 }) {
    this.state = initialState;
  }

  // 箭头函数保证作为参数传递时仍保留 this
  readonly getSnapshot = (): CounterState => {
    return this.state;
  };

  readonly subscribe = (listener: Listener): (() => void) => {
    this.listeners.add(listener);

    return () => {
      this.listeners.delete(listener);
    };
  };

  increment(): void {
    this.state = {
      ...this.state,
      count: this.state.count + 1,
    };

    // 先完成状态替换,再通知 React 重新读取快照
    for (const listener of this.listeners) {
      listener();
    }
  }

  decrement(): void {
    this.state = {
      ...this.state,
      count: this.state.count - 1,
    };

    for (const listener of this.listeners) {
      listener();
    }
  }
}

这里的因果顺序很重要:

状态替换
  ↓
通知订阅者
  ↓
React 再次调用 getSnapshot()
  ↓
比较新旧快照
  ↓
必要时重新渲染

如果先通知、后修改状态,React 重新调用 getSnapshot() 时可能仍然读到旧值。

组件接入 Store

// Counter.tsx
import { useSyncExternalStore } from "react";
import { CounterStore } from "./counter-store";

const counterStore = new CounterStore();

export function Counter() {
  const state = useSyncExternalStore(
    counterStore.subscribe,
    counterStore.getSnapshot,
  );

  return (
    <div>
      <p>当前值:{state.count}</p>

      <button onClick={() => counterStore.decrement()}>
        -1
      </button>

      <button onClick={() => counterStore.increment()}>
        +1
      </button>
    </div>
  );
}

点击按钮后的完整路径是:

onClick
  ↓
counterStore.increment()
  ↓
生成新的 { count: count + 1 }
  ↓
调用所有 listener
  ↓
useSyncExternalStore 重新调用 getSnapshot()
  ↓
发现新旧对象不是同一个对象
  ↓
Counter 重新渲染

useSyncExternalStore 不需要订阅者主动传入新状态:

listener(nextState); // 不需要

监听器只是一个“状态可能变化了”的通知。真正的状态读取始终由 React 通过 getSnapshot() 完成。


五、订阅函数的生命周期

subscribe 的职责不是返回状态,而是管理监听关系。

React 的典型生命周期可以抽象为:

sequenceDiagram
    participant R as React
    participant S as Store

    R->>S: subscribe(listener)
    S-->>R: unsubscribe
    S->>S: 状态发生变化
    S->>R: listener()
    R->>S: getSnapshot()
    S-->>R: 新快照
    R->>R: 比较快照并重新渲染
    R->>S: unsubscribe()

subscribe 必须返回幂等的取消函数:

const unsubscribe = store.subscribe(listener);

unsubscribe();
unsubscribe(); // 不应产生破坏性副作用

使用 Set 存储监听器通常能够自然满足这一点。

订阅函数的引用稳定性

不要在组件每次渲染时创建一个全新的 subscribe 函数:

function BadComponent() {
  const value = useSyncExternalStore(
    (listener) => store.subscribe(listener),
    () => store.getSnapshot(),
  );

  return <div>{value}</div>;
}

如果组件重新渲染,新的箭头函数可能让 React 认为订阅逻辑发生变化,从而取消旧订阅并建立新订阅。通常应将方法定义在 store 上,或者使用稳定的模块级函数:

function GoodComponent() {
  const value = useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
  );

  return <div>{value}</div>;
}

如果订阅参数确实依赖某个标识,也应明确处理依赖变化:

function UserName({ userId }: { userId: string }) {
  const subscribe = useCallback(
    (listener: () => void) => userStore.subscribe(userId, listener),
    [userId],
  );

  const getSnapshot = useCallback(
    () => userStore.getSnapshot(userId),
    [userId],
  );

  const user = useSyncExternalStore(subscribe, getSnapshot);

  return <span>{user.name}</span>;
}

这里 userId 变化时重新订阅是有意的;同一 userId 下则保持函数稳定。


六、并发渲染为什么需要一致性检查

1. 撕裂问题

“撕裂”(tearing)指的是同一次 UI 渲染中,不同组件读取了同一个外部状态的不同版本。

设外部状态从 A 变为 B

Store:A ───────────────> B

如果 React 正在渲染两个组件:

组件 A 读取到 A
外部 Store 变为 B
组件 B 读取到 B

最终可能提交:

组件 A 显示旧状态 A
组件 B 显示新状态 B

这不是普通的“组件晚更新了一次”,而是同一个 UI 树内部出现了互相矛盾的状态。

2. useSyncExternalStore 的一致性模型

可以用下面的变量表示一次渲染:

  • S_r:React 开始渲染时读取的快照;
  • S_c:React 准备提交时重新读取的快照;
  • :快照身份相同,即 Object.is(S_r, S_c) 为真。

React 需要验证:

S_r ≈ S_c

如果成立,说明从读取到提交期间外部状态没有改变,可以提交这次渲染。

如果不成立:

S_r ≠ S_c

说明渲染期间外部状态发生了变化,React 会丢弃或重新计算相关渲染,避免把混合版本提交到 UI。

这就是“一致快照”的核心:一次提交不能使用外部状态的多个版本

3. 读取函数必须是纯的

getSnapshot 应只读取状态,不应产生副作用:

// 正确
const getSnapshot = () => store.state;

// 错误:读取时修改状态
const getSnapshot = () => {
  store.state.count += 1;
  return store.state;
};

如果 getSnapshot 自己修改状态,React 的“读取—比较—重新读取”过程就失去意义,可能造成无限更新或难以诊断的竞态。


七、外部状态更新与 Transition 的边界

React 的 Transition 允许把一部分更新标记为非紧急更新:

startTransition(() => {
  setRoute("/reports");
});

但是外部 store 的更新不是由 React 的状态队列管理的。React 官方对 useSyncExternalStore 的一致性要求意味着:

如果外部 store 在一个非阻塞 Transition 期间发生变化,React 可能将相关更新作为阻塞更新重新处理,以保证读取到一致快照。

因此不能假设所有外部 store 更新都天然具有 React Transition 的可中断语义。

例如:

startTransition(() => {
  routerStore.setRoute("/reports");
});

如果 routerStore 是通过 useSyncExternalStore 接入的,外部 store 的同步变化仍需满足一致快照要求。startTransition 并不会把一个任意外部可变对象自动变成 React 管理的并发状态。

这也是为什么 store 更新必须:

  1. 先原子地替换快照;
  2. 再同步通知订阅者;
  3. 让 React 重新读取完整快照。

八、SSR:为什么需要 getServerSnapshot

服务端没有浏览器 DOM,也通常没有持久存在于多个请求之间的用户状态。但服务端仍可能渲染使用外部 store 的组件。

在服务端渲染期间:

const value = useSyncExternalStore(
  subscribe,
  getSnapshot,
  getServerSnapshot,
);

React 使用 getServerSnapshot() 生成服务端 HTML。客户端 hydration 的初始阶段也需要得到与服务端相同的数据。

可以把 hydration 的必要条件写成:

服务端首次输出的快照 = 客户端 hydration 初始快照

这里的“相同”是指渲染结果所依赖的数据相同,而不要求服务端和浏览器进程中的对象引用相同。它们通常处于不同 JavaScript 环境,无法共享对象身份。

如果没有第三个参数,服务端渲染时 React 无法安全地读取客户端版本的 store,通常会报错或无法完成正确的 SSR。


九、一个可用于 SSR 的 Store 设计

服务端不能把每个请求的用户状态放进跨请求共享的模块级单例中:

// SSR 中有风险
export const store = new CounterStore();

如果请求 A 修改了这个 store,请求 B 可能读到请求 A 的状态,形成数据泄漏。

更安全的结构是每个请求创建一个 store:

import { useSyncExternalStore } from "react";
import type { CounterState, CounterStore } from "./counter-store";

type CounterProps = {
  store: CounterStore;
  serverSnapshot: CounterState;
};

export function Counter({
  store,
  serverSnapshot,
}: CounterProps) {
  const state = useSyncExternalStore(
    store.subscribe,
    store.getSnapshot,
    () => serverSnapshot,
  );

  return (
    <div>
      <p>当前值:{state.count}</p>
      <button onClick={() => store.increment()}>+1</button>
    </div>
  );
}

服务端:

import { renderToString } from "react-dom/server";
import { CounterStore } from "./counter-store";
import { Counter } from "./Counter";

export function renderPage() {
  const initialState = { count: 10 };
  const store = new CounterStore(initialState);

  const html = renderToString(
    <Counter
      store={store}
      serverSnapshot={initialState}
    />,
  );

  return html;
}

客户端 hydration 时,必须用同一份初始数据创建客户端 store:

import { hydrateRoot } from "react-dom/client";
import { CounterStore } from "./counter-store";
import { Counter } from "./Counter";

declare global {
  interface Window {
    __INITIAL_COUNTER_STATE__: {
      count: number;
    };
  }
}

const initialState = window.__INITIAL_COUNTER_STATE__;
const store = new CounterStore(initialState);

hydrateRoot(
  document.getElementById("root")!,
  <Counter
    store={store}
    serverSnapshot={initialState}
  />,
);

真实应用需要将 __INITIAL_COUNTER_STATE__ 安全地序列化到 HTML 中,不能直接把未经转义的用户数据拼接进 <script>。例如用户名称可能包含 </script>,不安全序列化会导致 HTML 结构破坏或脚本注入。

SSR 的关键数据流是:

请求进入
  ↓
按请求创建 store
  ↓
加载该请求所需的初始数据
  ↓
getServerSnapshot() 返回初始数据
  ↓
服务端生成 HTML
  ↓
将同一份初始数据安全传给浏览器
  ↓
浏览器创建初始状态相同的 store
  ↓
hydrateRoot()
  ↓
hydration 完成后使用客户端 getSnapshot()

getServerSnapshot 不能读取浏览器 API

下面的实现不能用于服务端渲染:

const getServerSnapshot = () => {
  return window.localStorage.getItem("theme");
};

服务端没有 window。应当让服务端返回明确的默认值,并在客户端用同一初始值开始 hydration:

const serverTheme = "light";

const getServerSnapshot = () => serverTheme;

const getSnapshot = () => themeStore.getSnapshot();

如果客户端一开始直接读取本地存储,而服务端输出的是另一个主题,就可能产生 hydration 不一致:

服务端:class="light"
客户端首次读取:class="dark"

此时应选择一种明确策略:

  • 服务端也能获得相同主题数据;
  • 先用服务端默认值完成 hydration,再在客户端更新;
  • 将依赖浏览器环境的组件放在客户端边界中。

十、客户端边界与 React Server Components

在使用 React Server Components 的框架中,useSyncExternalStore 属于客户端 Hook。使用它的组件必须处于客户端组件边界内,例如在支持该约定的框架中使用:

"use client";

import { useSyncExternalStore } from "react";

这并不意味着该组件绝对不能参与 SSR。客户端组件仍可能被服务端预渲染 HTML,但它的 Hook 逻辑必须满足 SSR 条件,也就是提供合适的 getServerSnapshot,并避免在渲染阶段直接访问 windowdocument 等浏览器对象。

“客户端组件”和“只在浏览器渲染”不是同一个概念:

  • 客户端组件:允许使用客户端 Hook 和浏览器交互;
  • 只在浏览器渲染:服务端根本不执行该组件的初始渲染。

具体框架如何划分边界属于框架约定,但 useSyncExternalStore 本身的 SSR 要求不变。


十一、浏览器 API 示例:在线状态

浏览器提供的 navigator.onLine 是典型外部状态。它不是 React 状态,变化通过 onlineoffline 事件通知。

import { useSyncExternalStore } from "react";

function subscribe(listener: () => void): () => void {
  window.addEventListener("online", listener);
  window.addEventListener("offline", listener);

  return () => {
    window.removeEventListener("online", listener);
    window.removeEventListener("offline", listener);
  };
}

function getSnapshot(): boolean {
  return navigator.onLine;
}

function getServerSnapshot(): boolean {
  // 服务端无法知道当前浏览器网络状态
  return true;
}

export function NetworkStatus() {
  const online = useSyncExternalStore(
    subscribe,
    getSnapshot,
    getServerSnapshot,
  );

  return (
    <p>
      当前状态:{online ? "在线" : "离线"}
    </p>
  );
}

这里有三个边界:

  1. subscribe 只在浏览器环境中使用,因此访问 window 是可行的;
  2. getSnapshot 只应在客户端读取 navigator.onLine
  3. getServerSnapshot 必须返回一个服务端可用的确定值。

服务端输出“在线”并不能证明浏览器真的在线。它只是 hydration 之前的占位快照。浏览器接管后,getSnapshot() 会读取真实状态。


十二、外部快照必须覆盖“组件所需的完整数据”

不要让不同组件以不一致的方式拼接外部状态。

例如:

function BadComponent() {
  const user = useSyncExternalStore(
    userStore.subscribe,
    () => userStore.getUser(),
  );

  const permission = permissionStore.getPermission();

  return user && permission ? <AdminPanel /> : null;
}

这里用户数据和权限数据可能来自两个独立时间点。一个渲染过程可能得到:

user:新版本
permission:旧版本

如果它们必须原子一致,应让 store 提供一个完整快照:

type AppSnapshot = {
  user: User | null;
  permission: Permission;
};

const appStore = {
  getSnapshot(): AppSnapshot {
    return currentSnapshot;
  },

  subscribe(listener: () => void) {
    listeners.add(listener);
    return () => listeners.delete(listener);
  },
};

组件读取:

function AdminPanelEntry() {
  const snapshot = useSyncExternalStore(
    appStore.subscribe,
    appStore.getSnapshot,
  );

  if (!snapshot.user || snapshot.permission !== "admin") {
    return null;
  }

  return <AdminPanel />;
}

形式上,若两个字段必须属于同一个版本,应满足:

Snapshot_n = {
  user: user_n,
  permission: permission_n
}

而不是在一次渲染中分别读取:

user_n + permission_(n-1)

外部 store 的“快照”不仅是返回值类型,更是数据一致性的边界。


十三、不可变更新与错误示例

错误:原地修改后返回同一个对象

type State = { count: number };

let state: State = { count: 0 };
const listeners = new Set<() => void>();

function getSnapshot() {
  return state;
}

function increment() {
  state.count += 1;
  listeners.forEach((listener) => listener());
}

变化过程是:

修改前:state 引用 X,count = 0
原地修改:state 引用仍为 X,count = 1
通知 React
React 读取:仍然得到引用 X

如果 React 依赖对象身份判断快照是否变化,就可能跳过更新。

正确:替换根对象

function increment() {
  state = {
    ...state,
    count: state.count + 1,
  };

  listeners.forEach((listener) => listener());
}

变化过程是:

修改前:state 引用 X,count = 0
创建新对象:state 引用 Y,count = 1
通知 React
React 读取:得到 Y
Object.is(X, Y) === false

如果快照包含嵌套对象,也要根据更新范围正确替换引用:

state = {
  ...state,
  user: {
    ...state.user,
    name: "Grace",
  },
};

不变的分支可以继续共享引用;发生变化的路径需要创建新对象。


十四、通知本身不代表一定会重新渲染

调用监听器只是通知 React“请重新检查”。React 仍会调用 getSnapshot() 并比较结果。

function notify() {
  for (const listener of listeners) {
    listener();
  }
}

如果 store 错误地通知了,但快照没有变化:

notify();

React 可以跳过不必要的渲染。

反过来,如果 store 改了快照却没有通知:

state = { count: 100 };
// 忘记 notify()

React 没有理由主动重新读取这个 store,组件可能继续显示旧内容。

因此,外部 store 至少需要保持这个不变量:

快照发生变化 ⇒ 通知所有当前订阅者

通常还应满足:

通知订阅者时,getSnapshot 已经能读到新快照

十五、订阅和清理中的常见问题

1. 忘记移除监听器

错误的 API:

subscribe(listener: Listener): void {
  listeners.add(listener);
}

组件卸载后监听器仍然存在,会导致:

  • 内存无法释放;
  • 已卸载组件仍收到通知;
  • 开发环境中重复挂载后出现多次回调;
  • 调试时看到同一个更新触发越来越多次。

正确 API 必须返回取消订阅函数:

subscribe(listener: Listener) {
  listeners.add(listener);

  return () => {
    listeners.delete(listener);
  };
}

2. 把事件对象当成快照

一些事件系统会传递事件参数:

element.addEventListener("change", (event) => {
  // event 是事件,不一定是完整状态
});

useSyncExternalStore 的监听器不接收状态参数:

(listener: () => void) => () => void

事件回调应先更新 store,再通知 React:

function handleMessage(event: MessageEvent) {
  store.replace(parseMessage(event.data));
}

而不是试图让 React 从某个事件对象中推断完整状态。

3. 在回调中直接修改组件逻辑

订阅回调应该保持简单:

const subscribe = (listener: () => void) => {
  externalSource.onChange(listener);
  return () => externalSource.offChange(listener);
};

状态转换、校验和错误状态应由 store 完成,React 通过 getSnapshot 读取结果。


十六、异步数据和错误处理

useSyncExternalStore 不负责发起请求、缓存请求或解析错误。外部 store 需要把异步过程表示成可读取的快照:

type RequestState<T> =
  | { status: "idle"; data: null; error: null }
  | { status: "loading"; data: null; error: null }
  | { status: "success"; data: T; error: null }
  | { status: "error"; data: null; error: Error };

组件只读取这个状态:

function UserPanel() {
  const request = useSyncExternalStore(
    userStore.subscribe,
    userStore.getSnapshot,
  );

  switch (request.status) {
    case "idle":
      return <button onClick={() => userStore.load()}>加载</button>;

    case "loading":
      return <p>加载中……</p>;

    case "success":
      return <p>{request.data.name}</p>;

    case "error":
      return (
        <div>
          <p>加载失败:{request.error.message}</p>
          <button onClick={() => userStore.load()}>重试</button>
        </div>
      );
  }
}

请求流程应类似:

idle
  ↓ load()
loading
  ├─ 成功 → success
  └─ 失败 → error

如果异步任务存在竞态,还需要在 store 中处理请求版本:

let requestId = 0;

async function load() {
  const currentId = ++requestId;
  setState({ status: "loading", data: null, error: null });

  try {
    const data = await fetchUser();

    if (currentId !== requestId) {
      return; // 丢弃过期请求的结果
    }

    setState({ status: "success", data, error: null });
  } catch (error) {
    if (currentId !== requestId) {
      return;
    }

    setState({
      status: "error",
      data: null,
      error: error instanceof Error ? error : new Error(String(error)),
    });
  }
}

这样可以避免请求 A 晚于请求 B 返回时,用旧结果覆盖新结果。这个竞态控制属于外部 store 的职责,不是 useSyncExternalStore 自动提供的能力。


十七、选择器、派生值和性能

useSyncExternalStore 返回的是完整快照。组件可以在读取后计算派生值:

function CountLabel() {
  const state = useSyncExternalStore(
    counterStore.subscribe,
    counterStore.getSnapshot,
  );

  const isEven = state.count % 2 === 0;

  return <span>{isEven ? "偶数" : "奇数"}</span>;
}

如果快照很大,多个组件都订阅整个快照,任何字段变化都可能使它们重新检查。此时可以:

  • 将状态拆成有明确一致性边界的多个 store;
  • 让快照保持结构共享;
  • 在组件层使用稳定的派生计算;
  • 使用经过验证的 selector 封装。

React 核心 API 本身提供的是 useSyncExternalStore,并不直接提供带 selector 的第三方状态管理语义。项目若使用 use-sync-external-store/with-selector 或状态库的 selector API,应按照对应包的版本和文档使用,不能把它们当作 React 内置 Hook 的第三个参数。

尤其不要这样写:

const selected = useSyncExternalStore(
  store.subscribe,
  () => ({ count: store.getSnapshot().count }),
);

这个 getSnapshot 每次调用都创建新对象,即使 count 没变,也会破坏快照缓存条件。若确实要返回派生对象,应在 store 中缓存它,或者使用专门提供 selector 支持的实现。


十八、何时不应使用 useSyncExternalStore

如果数据完全由当前组件或当前 React 子树拥有,优先使用:

useState
useReducer
useContext

例如表单输入值、弹窗是否打开、当前组件内部的分页状态,通常不需要外部 store。

useSyncExternalStore 更适合以下条件:

状态由 React 外部更新
并且
React 组件需要订阅它

如果只是把一个普通函数调用结果显示出来:

function Component() {
  return <span>{readConfig()}</span>;
}

没有外部变化通知,就无法仅靠 useSyncExternalStore 获得更新。它不是定时重新读取机制,也不是任意数据源的自动响应式包装器。


十九、诊断清单:从表现反推协议错误

组件不更新

检查:

外部状态更新后是否调用了所有 listener?

常见原因是修改了对象却忘记通知,或通知注册在了错误的数据源上。

组件显示旧数据

检查:

getSnapshot() 是否真的返回当前状态?

常见原因是 getSnapshot 读取了过期缓存,或者更新只修改了另一个对象。

出现无限渲染或缓存警告

检查:

getSnapshot() 在状态不变时是否返回同一个值或同一个对象?

尤其搜索:

() => ({ ... })
() => array.map(...)
() => JSON.parse(...)

这些表达式每次都会创建新引用。

SSR hydration 警告

比较服务端和客户端首次渲染依赖的数据:

服务端 getServerSnapshot()
客户端 hydration 期间的初始快照

检查时间、随机数、浏览器存储、用户会话和请求数据是否在两侧一致。

服务端用户数据串请求

检查是否存在模块级可变 store:

export const store = new Store();

在 SSR 中,服务端 store 应按请求创建,不能让不同请求共享用户相关状态。

开发环境中订阅次数变多

先确认是否处于 Strict Mode 或开发热更新环境。React 可能为了发现副作用而进行额外的挂载、清理和重新挂载。真正需要保证的是:

每次订阅都有对应取消
取消函数不会破坏状态
重复订阅不会导致永久泄漏

二十、规范保证、实现细节与工程取舍

可以明确区分三类结论。

React API 协议要求的核心条件是:

  • subscribe 返回取消订阅函数;
  • getSnapshot 在状态不变时返回缓存结果;
  • 外部状态变化后通知订阅者;
  • SSR 使用时提供正确的 getServerSnapshot
  • 渲染读取函数不产生副作用。

常见实现方式包括:

  • Set<Listener> 管理订阅者;
  • 用不可变替换保证快照引用变化;
  • 在模块级维护浏览器端单例 store;
  • 在服务端按请求创建 store;
  • 使用事件源触发统一的无参数 listener。

工程上需要根据数据一致性边界做取舍:

  • 单一大 store 便于原子读取,但可能让无关组件频繁检查;
  • 多个小 store 可以减少影响范围,但跨 store 一致性更难维护;
  • 浏览器单例适合客户端共享状态,但不能直接作为 SSR 的跨请求状态;
  • 服务端快照适合 hydration,但依赖浏览器的值必须有可解释的初始策略。

useSyncExternalStore 的核心价值不是减少代码,而是为 React 和外部可变数据之间建立一个有约束的读取协议:

订阅变化
  + 读取稳定快照
  + 提交前一致性检查
  + SSR 初始快照

只要外部 store 遵守这套协议,React 就能在普通渲染、并发渲染、卸载重订阅以及服务端 hydration 这些场景中,可靠地把外部状态映射为组件 UI。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。