Hook18.0+

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 支持的外部状态订阅

技术规格

类型签名

TypeScript
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 状态:

TypeScript
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 媒体查询变化(如深色模式、窗口大小):

TypeScript
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:

TypeScript
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:

TypeScript
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 执行时机不确定,可能错过状态更新

❌ 错误代码:

TypeScript
// ❌ 使用 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;
}

✅ 修正代码:

TypeScript
// ✅ 使用 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"。

❌ 错误代码:

TypeScript
// ❌ 缺少 getServerSnapshot
function useWindowWidth() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener('resize', callback);
      return () => window.removeEventListener('resize', callback);
    },
    () => window.innerWidth
    // 缺少第三个参数!
  );
}

✅ 修正代码:

TypeScript
// ✅ 提供 getServerSnapshot
function useWindowWidth() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener('resize', callback);
      return () => window.removeEventListener('resize', callback);
    },
    () => window.innerWidth,
    () => 1024 // 服务器端默认值
  );
}

陷阱 3: getSnapshot 返回值不稳定

问题:如果 getSnapshot 每次调用都返回新的对象引用, React 会认为状态总是变化,导致无限重新渲染。

后果:性能问题,组件无限循环渲染。

❌ 错误代码:

TypeScript
// ❌ getSnapshot 每次返回新对象
function useStore() {
  return useSyncExternalStore(
    store.subscribe,
    () => ({ ...store.getState() }), // 每次创建新对象!
    () => ({ ...store.getState() })
  );
}

✅ 修正代码:

TypeScript
// ✅ getSnapshot 返回稳定引用
function useStore() {
  return useSyncExternalStore(
    store.subscribe,
    () => store.getState(), // 返回同一个引用
    () => store.getState()
  );
}

心智模型纠正:React 使用 Object.is 比较快照。 如果 getSnapshot 每次返回新对象,React 总是检测到变化,导致无限渲染。 确保 getSnapshot 返回稳定的引用(基本类型或不可变对象)。

陷阱 4: 在 subscribe 中执行副作用

问题:subscribe 函数应该只注册监听器和返回清理函数, 不应该执行其他副作用(如修改状态、调用 API)。

后果:不可预测的行为,可能的内存泄漏或性能问题。

❌ 错误代码:

TypeScript
// ❌ 在 subscribe 中执行副作用
function useData() {
  return useSyncExternalStore(
    (callback) => {
      fetchData(); // 不应该在这里调用 API!
      window.addEventListener('focus', callback);
      return () => window.removeEventListener('focus', callback);
    },
    () => cache.getData(),
    () => null
  );
}

✅ 修正代码:

TypeScript
// ✅ 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。

TypeScript
// 创建可复用的 Hook
export function useOnlineStatus() {
  return useSyncExternalStore(
    subscribeToOnlineStatus,
    getOnlineSnapshot,
    getServerOnlineSnapshot
  );
}

// 在组件中使用
function App() {
  const isOnline = useOnlineStatus();
  // ...
}

2. 使用 Selector 避免不必要的渲染

只订阅需要的状态片段,避免整个 store 变化时重新渲染。

TypeScript
// 使用 selector 只订阅需要的状态
function useUserName() {
  return useSyncExternalStore(
    store.subscribe,
    () => store.getState().user.name, // 只返回 name
    () => ''
  );
}

3. 确保 getSnapshot 返回稳定值

getSnapshot 应该返回基本类型或不可变对象,避免每次创建新对象。

TypeScript
// getSnapshot 返回基本类型或不可变对象
function useCount() {
  return useSyncExternalStore(
    store.subscribe,
    () => store.getState().count, // 返回数字,稳定
    () => 0
  );
}

4. 提供 SSR 兼容的 getServerSnapshot

确保外部存储在 SSR 环境能正常工作。

TypeScript
// 提供合理的服务器端默认值
function useTheme() {
  return useSyncExternalStore(
    subscribeToTheme,
    () => localStorage.getItem('theme') || 'light',
    () => 'light' // SSR 默认浅色主题
  );
}

5. 正确清理订阅

subscribe 返回的清理函数必须正确移除所有监听器,避免内存泄漏。

TypeScript
// 正确清理所有监听器
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 对比

特性useSyncExternalStoreuseEffect
并发渲染安全✅ 是,避免状态撕裂❌ 否,可能出现状态不一致
SSR 支持✅ 通过 getServerSnapshot❌ 需要额外处理
订阅时序✅ React 控制,更高效⚠️ effect 执行时机不确定
使用复杂度⚠️ 稍复杂,需要提供3个参数✅ 简单,熟悉的使用模式
适用场景外部存储订阅副作用执行

使用建议

  • 优先使用 useSyncExternalStore 订阅外部存储,而不是 useEffect
  • 封装自定义 Hook,简化使用并增强类型安全
  • 确保 getSnapshot 返回稳定的引用,避免无限渲染
  • 始终提供 getServerSnapshot,确保 SSR 兼容
  • 对于大多数应用状态,仍然使用 useState/useReducer,不要过度使用 useSyncExternalStore