useSyncExternalStore
订阅外部存储并同步状态到 React 组件
核心概述
在 React 应用中,我们经常需要与外部存储系统集成,如:
- 浏览器 API(localStorage、navigator.onLine、window.matchMedia)
- 第三方状态管理库(Redux、Zustand、Jotai)
- 网络状态、媒体查询、设备方向等
在 React 18 之前,开发者通常使用 useEffect 订阅外部存储, 但这种方式在并发渲染和服务器渲染(SSR)场景下会导致问题:
- 状态撕裂(Tearing):并发渲染时,外部状态变化导致 UI 显示不一致
- SSR 不匹配:服务器和客户端渲染结果不一致,导致水合错误
- 订阅时序问题:effect 执行时机不确定,可能导致错过状态更新
useSyncExternalStore 是 React 18 引入的专用 Hook, 专门用于订阅外部存储。它确保在并发渲染和 SSR 环境下,外部状态与 React 状态保持同步。
💡 心智模型
将 useSyncExternalStore 想象成"双向同步桥梁":
- • 订阅外部变化:桥梁一端连接外部存储,当外部状态变化时, 自动通知 React 重新渲染
- • 读取快照:桥梁另一端连接 React,每次渲染时读取最新状态快照
- • 并发安全:桥梁确保在并发渲染过程中,UI 始终显示一致的状态
- • SSR 友好:桥梁允许指定服务器渲染时的初始快照,确保 SSR/CSR 一致
典型应用场景:
- 集成浏览器 API(navigator.onLine、localStorage、matchMedia)
- 集成第三方状态管理库(Redux、Zustand 等库的底层实现)
- 订阅网络状态、设备信息、地理位置等
- 需要 SSR 支持的外部状态订阅
技术规格
类型签名
function useSyncExternalStore<Snapshot>(
subscribe: (onStoreChange: () => void) => () => void,
getSnapshot: () => Snapshot,
getServerSnapshot?: () => Snapshot
): Snapshot参数说明
| 参数 | 类型 | 说明 |
|---|---|---|
subscribe | (callback) => () => void | 订阅函数,接收一个回调。当外部存储变化时,应该调用这个回调。 返回取消订阅函数(cleanup) |
getSnapshot | () => T | 返回当前状态快照的函数。React 会在渲染时调用它获取最新值 |
getServerSnapshot | () => T | 可选。服务器渲染时的快照函数。如果外部存储在 SSR 环境不可用, 必须提供此参数,否则会报错 |
返回值
返回外部存储的当前状态快照,类型为 T
运行机制
订阅机制:
- 组件挂载时,React 调用
subscribe(callback)注册回调 - 外部存储变化时,调用回调通知 React
- React 调用
getSnapshot()获取最新值 - 如果快照变化(使用
Object.is比较),触发组件重新渲染
并发渲染安全:
- 在并发渲染过程中,React 会多次调用
getSnapshot()确保状态一致 - 避免状态撕裂:保证 UI 始终显示外部存储的某个一致状态
- 如果外部存储在渲染期间变化,React 会重新开始渲染
SSR 支持:
- 服务器渲染时,调用
getServerSnapshot?获取初始快照 - 确保服务器和客户端渲染结果一致,避免水合错误
- 如果外部存储在服务器不可用,必须提供
getServerSnapshot
实战演练
示例 1: 监听网络状态
最简单的用法是订阅浏览器的 online/offline 状态:
import { useSyncExternalStore } from 'react';
function subscribe(callback: () => void) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function getSnapshot() {
return navigator.onLine;
}
function getServerSnapshot() {
return true; // 服务器端默认返回 true
}
export function useOnlineStatus() {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
// 使用示例
function StatusBar() {
const isOnline = useOnlineStatus();
return <div>{isOnline ? '✅ 在线' : '❌ 离线'}</div>;
}示例 2: 订阅媒体查询
监听 CSS 媒体查询变化(如深色模式、窗口大小):
import { useSyncExternalStore, useCallback } from 'react';
export function useMediaQuery(query: string) {
const subscribe = useCallback((callback: () => void) => {
const mediaQuery = window.matchMedia(query);
mediaQuery.addEventListener('change', callback);
return () => mediaQuery.removeEventListener('change', callback);
}, [query]);
const getSnapshot = useCallback(() => {
return window.matchMedia(query).matches;
}, [query]);
const getServerSnapshot = () => false;
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
// 使用示例
function DarkModeToggle() {
const isDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
return <div>当前模式: {isDarkMode ? '深色' : '浅色'}</div>;
}示例 3: 集成 Redux(生产级)
展示如何使用 useSyncExternalStore 集成 Redux store:
import { useSyncExternalStore } from 'react';
import { store } from './store';
// 通用的 Redux Hook
export function useSelector<T>(selector: (state: RootState) => T): T {
return useSyncExternalStore(
store.subscribe,
() => selector(store.getState()),
() => selector(store.getState())
);
}
// 使用示例
function Counter() {
const count = useSelector(state => state.counter.value);
return <div>Count: {count}</div>;
}示例 4: 订阅 localStorage(生产级)
创建一个类型安全的 localStorage Hook:
import { useSyncExternalStore, useCallback } from 'react';
export function useLocalStorage<T>(key: string, initialValue: T) {
const subscribe = useCallback((callback: () => void) => {
window.addEventListener('storage', callback);
return () => window.removeEventListener('storage', callback);
}, []);
const getSnapshot = useCallback(() => {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
}, [key, initialValue]);
const getServerSnapshot = () => initialValue;
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}避坑指南
陷阱 1: 使用 useEffect 订阅外部存储
问题:在 React 18 之前,开发者常用 useEffect 订阅外部存储, 但这种方式在并发渲染和 SSR 场景下会导致严重问题。
后果:
- 状态撕裂:并发渲染时,外部状态变化导致 UI 显示不一致
- SSR 不匹配:服务器和客户端渲染结果不同,水合错误
- 订阅时序问题:effect 执行时机不确定,可能错过状态更新
❌ 错误代码:
// ❌ 使用 useEffect 订阅外部存储
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}✅ 修正代码:
// ✅ 使用 useSyncExternalStore
function useOnlineStatus() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
() => navigator.onLine,
() => true
);
}心智模型纠正:useEffect 是"副作用执行器", 不是"状态订阅器"。外部状态订阅应该使用专用 Hook useSyncExternalStore, 它能正确处理并发渲染和 SSR。
陷阱 2: 忘记提供 getServerSnapshot
问题:如果外部存储在 SSR 环境不可用(如 window、navigator), 必须提供 getServerSnapshot 参数。
后果:服务器渲染时报错 "ReferenceError: window is not defined"。
❌ 错误代码:
// ❌ 缺少 getServerSnapshot
function useWindowWidth() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
},
() => window.innerWidth
// 缺少第三个参数!
);
}✅ 修正代码:
// ✅ 提供 getServerSnapshot
function useWindowWidth() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('resize', callback);
return () => window.removeEventListener('resize', callback);
},
() => window.innerWidth,
() => 1024 // 服务器端默认值
);
}陷阱 3: getSnapshot 返回值不稳定
问题:如果 getSnapshot 每次调用都返回新的对象引用, React 会认为状态总是变化,导致无限重新渲染。
后果:性能问题,组件无限循环渲染。
❌ 错误代码:
// ❌ getSnapshot 每次返回新对象
function useStore() {
return useSyncExternalStore(
store.subscribe,
() => ({ ...store.getState() }), // 每次创建新对象!
() => ({ ...store.getState() })
);
}✅ 修正代码:
// ✅ getSnapshot 返回稳定引用
function useStore() {
return useSyncExternalStore(
store.subscribe,
() => store.getState(), // 返回同一个引用
() => store.getState()
);
}心智模型纠正:React 使用 Object.is 比较快照。 如果 getSnapshot 每次返回新对象,React 总是检测到变化,导致无限渲染。 确保 getSnapshot 返回稳定的引用(基本类型或不可变对象)。
陷阱 4: 在 subscribe 中执行副作用
问题:subscribe 函数应该只注册监听器和返回清理函数, 不应该执行其他副作用(如修改状态、调用 API)。
后果:不可预测的行为,可能的内存泄漏或性能问题。
❌ 错误代码:
// ❌ 在 subscribe 中执行副作用
function useData() {
return useSyncExternalStore(
(callback) => {
fetchData(); // 不应该在这里调用 API!
window.addEventListener('focus', callback);
return () => window.removeEventListener('focus', callback);
},
() => cache.getData(),
() => null
);
}✅ 修正代码:
// ✅ subscribe 只负责订阅
function useData() {
return useSyncExternalStore(
(callback) => {
window.addEventListener('focus', callback);
return () => window.removeEventListener('focus', callback);
},
() => cache.getData(),
() => null
);
}
// 数据获取应该在 useEffect 中
useEffect(() => {
fetchData();
}, []);最佳实践
✅ 推荐模式
1. 创建可复用的自定义 Hook
封装 useSyncExternalStore,创建类型安全的自定义 Hook。
// 创建可复用的 Hook
export function useOnlineStatus() {
return useSyncExternalStore(
subscribeToOnlineStatus,
getOnlineSnapshot,
getServerOnlineSnapshot
);
}
// 在组件中使用
function App() {
const isOnline = useOnlineStatus();
// ...
}2. 使用 Selector 避免不必要的渲染
只订阅需要的状态片段,避免整个 store 变化时重新渲染。
// 使用 selector 只订阅需要的状态
function useUserName() {
return useSyncExternalStore(
store.subscribe,
() => store.getState().user.name, // 只返回 name
() => ''
);
}3. 确保 getSnapshot 返回稳定值
getSnapshot 应该返回基本类型或不可变对象,避免每次创建新对象。
// getSnapshot 返回基本类型或不可变对象
function useCount() {
return useSyncExternalStore(
store.subscribe,
() => store.getState().count, // 返回数字,稳定
() => 0
);
}4. 提供 SSR 兼容的 getServerSnapshot
确保外部存储在 SSR 环境能正常工作。
// 提供合理的服务器端默认值
function useTheme() {
return useSyncExternalStore(
subscribeToTheme,
() => localStorage.getItem('theme') || 'light',
() => 'light' // SSR 默认浅色主题
);
}5. 正确清理订阅
subscribe 返回的清理函数必须正确移除所有监听器,避免内存泄漏。
// 正确清理所有监听器
function subscribe(callback: () => void) {
const controller = new AbortController();
window.addEventListener('online', callback, { signal: controller.signal });
window.addEventListener('offline', callback, { signal: controller.signal });
return () => controller.abort(); // 一次性清理所有监听器
}useSyncExternalStore vs useEffect 对比
| 特性 | useSyncExternalStore | useEffect |
|---|---|---|
| 并发渲染安全 | ✅ 是,避免状态撕裂 | ❌ 否,可能出现状态不一致 |
| SSR 支持 | ✅ 通过 getServerSnapshot | ❌ 需要额外处理 |
| 订阅时序 | ✅ React 控制,更高效 | ⚠️ effect 执行时机不确定 |
| 使用复杂度 | ⚠️ 稍复杂,需要提供3个参数 | ✅ 简单,熟悉的使用模式 |
| 适用场景 | 外部存储订阅 | 副作用执行 |
使用建议
- 优先使用 useSyncExternalStore 订阅外部存储,而不是 useEffect
- 封装自定义 Hook,简化使用并增强类型安全
- 确保 getSnapshot 返回稳定的引用,避免无限渲染
- 始终提供 getServerSnapshot,确保 SSR 兼容
- 对于大多数应用状态,仍然使用 useState/useReducer,不要过度使用 useSyncExternalStore