useSyncExternalStore 是 React 18 引入的 Hook,用来让组件读取并订阅 React 之外的状态。聪明的你可能会问:什么算“外部状态”?简单说,就是状态由浏览器 API 或第三方 Store 等地方管理,React 不会自动知道它什么时候变化。

拿 navigator.onLine 举例,在线状态由浏览器维护,想在组件里显示在线或离线,我们通常会先想到用 useEffect + useState 监听变化:

import { useEffect, useState } from "react"

function OnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine)

  useEffect(() => {
    const handle = () => setIsOnline(navigator.onLine)
    window.addEventListener("online", handle)
    window.addEventListener("offline", handle)
    // 组件卸载时,移除这次注册的监听器。
    return () => {
      window.removeEventListener("online", handle)
      window.removeEventListener("offline", handle)
    }
  }, [])

  return <p>{isOnline ? "在线" : "离线"}</p>
}

但是 useEffect + useState 可能漏掉变化

问题藏在首次读取和建立订阅之间:

  1. 组件渲染时读取 navigator.onLine,得到 true。
  2. 在 Effect 建立监听之前,网络状态变成 false,offline 事件已经发出。
  3. Effect 随后才注册监听器。组件错过了这次通知,会一直保留旧值,直到下一次状态变化。

可以在注册监听器后立即调用一次 handle(),再读一遍当前值,避免漏掉刚才的变化。但如果多个组件读取同一个外部 Store,还要考虑 React 的并发渲染:一次渲染工作可能暂停、继续,期间外部数据却已经更新。自己把这些情况全部接好,就会越来越像在实现一个外部状态适配层。

另外,上面传给 useState 的初始值会在组件渲染时读取 navigator。把事件监听放进 useEffect 并不能保护渲染阶段的访问,SSR 环境依然会报错。

所以,这里要解决的是读取、订阅和渲染之间的一致性问题。useEffect 仍然适合处理副作用,事件监听也仍然需要清理。

让 React 负责读取快照和检查变化

useSyncExternalStore 接收订阅函数和快照读取函数。还是刚才的网络状态,把这两个函数直接传进去:

import { useSyncExternalStore } from "react"

function OnlineStatus() {
  const isOnline = useSyncExternalStore(
    // subscribe:订阅外部变化,并返回取消订阅的函数。
    (callback) => {
      // callback 只通知 React 数据可能变了,不需要把新值传进去。
      window.addEventListener("online", callback)
      window.addEventListener("offline", callback)
      return () => {
        window.removeEventListener("online", callback)
        window.removeEventListener("offline", callback)
      }
    },
    // getSnapshot:React 读取当前值,用来判断是否需要更新。
    () => navigator.onLine,
    // getServerSnapshot:服务端和首次 hydration 使用一致的初始值。
    () => true,
  )

  return <h1>{isOnline ? "在线" : "离线"}</h1>
}

subscribe 收到的 callback 是变化通知。调用它时不用传新值,React 会自己调用 getSnapshot,再通过 Object.is 比较快照,决定是否需要更新组件。

React 也会在建立订阅后检查快照,处理读取和订阅之间的变化。如果外部 Store 在一次非阻塞 Transition 更新期间发生改变,React 会在提交前再次检查快照,必要时重新以阻塞方式渲染,避免屏幕上的组件反映不同版本的数据。

这里把函数直接写在参数里,是为了方便看清每个参数的职责。实际复用时,可以把不依赖组件 props 或 state 的 subscribe 移到组件外,让函数引用保持稳定,避免每次渲染都重新订阅。

这个 Hook 也没有替你合并所有组件的监听器。多个组件分别订阅网络状态,仍然会各自注册监听器;如果需要共享底层监听,应由外部 Store 或适配层实现。

使用时需要注意的点

  1. SSR 的初始快照要一致。 getServerSnapshot 用于服务端渲染和客户端首次 hydration,两边必须返回相同的初始值。示例里的 true 表示先显示“在线”;hydration 后 React 会读取浏览器的实际值并更新页面,这不代表服务器知道用户的网络状况。服务端已有初始数据的 Store,应把同一份数据传到客户端。纯客户端渲染可以省略第三个参数,SSR 时不能省略。
  1. 快照要保持稳定。 网络示例返回布尔值,直接返回就可以。若返回对象,状态没变时要复用旧快照,状态改变时再返回新的不可变快照;每次创建新对象会让 Object.is 一直认为数据变了,而原地修改并复用同一个对象则会让 React 漏掉变化。