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 知道:
- 哪个组件拥有这份状态;
- 哪些组件需要重新渲染;
- 哪些渲染任务已经过期;
- 如何在提交 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 更新必须:
- 先原子地替换快照;
- 再同步通知订阅者;
- 让 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,并避免在渲染阶段直接访问 window、document 等浏览器对象。
“客户端组件”和“只在浏览器渲染”不是同一个概念:
- 客户端组件:允许使用客户端 Hook 和浏览器交互;
- 只在浏览器渲染:服务端根本不执行该组件的初始渲染。
具体框架如何划分边界属于框架约定,但 useSyncExternalStore 本身的 SSR 要求不变。
十一、浏览器 API 示例:在线状态
浏览器提供的 navigator.onLine 是典型外部状态。它不是 React 状态,变化通过 online 和 offline 事件通知。
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>
);
}
这里有三个边界:
subscribe只在浏览器环境中使用,因此访问window是可行的;getSnapshot只应在客户端读取navigator.onLine;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 完整学习路线:从渲染与 Hooks 到服务端组件和生产架构
- 上一篇:React lazy 与 Suspense:代码加载、边界、错误和用户体验
- 下一篇:React 身份与 useId:Key、DOM ID、水合和列表稳定性
官方资料
本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论