React 外部状态同步:别死磕 useEffect 了
处理那些不受 React 控制的数据(原生 DOM 状态、浏览器 API、自定义 Store),用 useEffect 手动同步非常麻烦,而且容易写出状态不一致的 Bug。
React 18 之后,官方其实给了一个更直接的工具:useSyncExternalStore。
核心痛点:状态双向同步
拿 navigator.onLine 举例,传统的 useEffect 写法:
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)
}
}, [])
这么写的痛点:
- 代码啰嗦:为了同步一个值,得维护监听器和状态两套逻辑。
- 状态脱节:如果你在代码其他地方误改了 isOnline,它就和真实的浏览器状态对不上了。
- SSR 软肋:
useEffect在服务端不执行,处理服务端渲染时的初始状态很麻烦.
更好的方案:useSyncExternalStore
useSyncExternalStore 这个 Hook 专门用于将 React 变量与外部存储同步。它只需要两个核心参数
subscribe:一个订阅函数,用于注册回调。每当外部数据变化时,它会通知 React 。getSnapshot:一个函数,用于获取外部数据的当前快照(即当前值)。
同样的网络状态功能,代码可以精简成这样:
function OnlineStatus() {
const isOnline = useSyncExternalStore(
// subscribe
(callback) => {
// 这个 callback 是 React 内部实现的“通知函数”
// 它的唯一任务:告诉 React “外面数据变了,你该干活了”
window.addEventListener("online", callback)
window.addEventListener("offline", callback)
return () => {
window.removeEventListener("online", callback)
window.removeEventListener("offline", callback)
}
},
// getSnapshot:React 听到“闹钟”响后,会调这个函数拿当前值
() => navigator.onLine,
// 服务端渲染默认值(可选)
() => true,
)
return <h1>{isOnline ? "在线" : "离线"}</h1>
}
场景进阶:处理原生 HTML5 <dialog> 的状态脱节
原生 <dialog> 标签有一个特性:用户按 Esc 键可以直接关闭它。如果你用 React 的 isOpen 状态去控制:
- 你点击按钮,
setIsOpen(true),弹窗开了。 - 用户按 Esc 键,弹窗在 DOM 层面关闭了。
- 但 React 里的
isOpen依然是true。
正确的做法:直接订阅 DOM 状态
我们不再自己维护一份 isOpen 变量,而是直接去问 <dialog> 元素:“你现在到底开没开?”
import { useSyncExternalStore, useRef } from "react"
function Modal() {
const dialogRef = useRef<HTMLDialogElement>(null)
// 订阅:监听 dialog 的 toggle 事件
const isOpen = useSyncExternalStore(
(callback) => {
const el = dialogRef.current
if (!el) return () => {}
// 只要 dialog 的打开状态变了(包括按 Esc),就通知 React
el.addEventListener("toggle", callback)
return () => el.removeEventListener("toggle", callback)
},
// 取数:直接从 DOM 获取真实状态
() => dialogRef.current?.open ?? false,
)
return (
<>
<button onClick={() => dialogRef.current?.showModal()}>打开弹窗</button>
<dialog ref={dialogRef}>
<p>这是一个原生弹窗</p>
<p>当前状态:{isOpen ? "开启" : "已关闭"}</p>
<button onClick={() => dialogRef.current?.close()}>手动关闭</button>
</dialog>
</>
)
}
为什么这么写更好?
- 单一数据源:状态只存在于
<dialog>元素本身,React 只是它的“观察者”。 - 无缝兼容原生行为:无论用户是通过点击按钮关闭,还是按 Esc 键关闭,
isOpen永远和屏幕上显示的结果一致。 - 逻辑自洽:你不需要在
useEffect里写一大堆判断逻辑去手动同步状态。
总结
“不要在 React 内部存副本,直接去外面拿真数据。” 这是这个 Hook 的核心逻辑。只要是同步非 React 管理的状态,它就是标准答案。