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 可能漏掉变化
问题藏在首次读取和建立订阅之间:
- 组件渲染时读取
navigator.onLine,得到true。 - 在 Effect 建立监听之前,网络状态变成
false,offline事件已经发出。 - 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 或适配层实现。
使用时需要注意的点
- SSR 的初始快照要一致。
getServerSnapshot用于服务端渲染和客户端首次 hydration,两边必须返回相同的初始值。示例里的true表示先显示“在线”;hydration 后 React 会读取浏览器的实际值并更新页面,这不代表服务器知道用户的网络状况。服务端已有初始数据的 Store,应把同一份数据传到客户端。纯客户端渲染可以省略第三个参数,SSR 时不能省略。
- 快照要保持稳定。 网络示例返回布尔值,直接返回就可以。若返回对象,状态没变时要复用旧快照,状态改变时再返回新的不可变快照;每次创建新对象会让
Object.is一直认为数据变了,而原地修改并复用同一个对象则会让 React 漏掉变化。