useEffect Subscription Trap
以前我写 network detection hook 的方式,和大多数 vibe coder 没什么区别:用一个 useEffect 去 subscribe NetInfo,在 connectivity 变化时更新 state,然后希望一切顺利。它在 development 里能跑,在 production 里会出事。
race condition、memory leak、stale closure。都是那些不会出现在 Cursor preview 里、却会在 crash log 里等着你的老朋友。
当你让 Claude Code 帮你写 network detection 时,它通常会生成这样的代码。看起来没问题,实际上对 production 来说是个 liability。
// What Cursor gives you. Works in dev. Breaks in prod.
function useNetwork() {
const [isConnected, setIsConnected] = useState(true)
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsConnected(!!state.isConnected)
})
return unsubscribe
}, [])
return isConnected
}
问题包括:callback 在 effect 运行之前触发时的 tearing;setup 过程中组件 unmount 时的 memory leak;callback 引用了 props 但 dependency array 写错时的 stale closure;以及每个 component 都重复写一份样板代码的 duplication。
这些就是会让用户打一星离开的那类 bug。
轮到 useSyncExternalStore 了
React 18 专门为这个场景引入了 useSyncExternalStore。它就是为了读取 external store 而设计的。任何带 subscribe function 和 getSnapshot function 的东西都可以接上它。NetInfo 是 external store,auth state 也是,任何会 emit event 的 native module 也都是。
与其自己写脆弱的 useEffect subscription,不如把难的部分交给 React。
// src/services/network/network.service.ts
// The service owns ONE native subscription.
import NetInfo from '@react-native-community/netinfo'
export type NetworkSnapshot = {
isConnected: boolean
isInternetReachable: boolean | null
connectionType: 'wifi' | 'cellular' | 'none' | 'unknown'
}
export interface NetworkService {
getSnapshot(): NetworkSnapshot
subscribe(listener: () => void): () => void
}
adapter 只维护一个 native subscription。NetInfo 一旦触发,它就更新自己的 internal snapshot,然后通知所有 listener。hook 的工作只是把这个 store 接到 React 上。
// src/hooks/use-network.ts
// Three lines. No useEffect. No cleanup. No stale closures.
import { useSyncExternalStore } from 'react'
export function useNetwork() {
const { network } = useServices()
return useSyncExternalStore(
network.subscribe, // React handles subscribe/unsubscribe
network.getSnapshot, // Read current value
network.getSnapshot // SSR snapshot
)
}
就这么简单。subscription lifecycle 归 React 管,native subscription 归 service 管,hook 只负责做桥接。component mount 时 React 会 subscribe,unmount 时 React 会 unsubscribe,store 变化时 React 会 re-render。你根本不用自己写这些逻辑。你不写,自然也就不容易写错。
一套 pattern,适用于所有 external store
这套方式扩展性很好。auth、network、permissions、lifecycle,任何来自 React 外部的 state 都可以套用同一个 shape。
// Every external store follows the same contract:
interface ExternalStore<T> {
getSnapshot(): T
subscribe(listener: () => void): () => void
}
// Every hook looks the same:
function useStore<T>(store: ExternalStore<T>): T {
return useSyncExternalStore(
store.subscribe,
store.getSnapshot
)
}
在 useSyncExternalStore 出现之前,我会为每一种 external data source 写一个自定义 useEffect hook。每一个都略有不同,每一个都带着自己的 bug。现在我只写一种 pattern,然后到处复用,把 subscription lifecycle 交给 React。
如果你现在还在 Vibe-coded React Native app 里用 useEffect 处理 external subscription,那你其实是在做更费劲的事情,同时产出更容易出 bug 的代码。这个 hook 已经存在了,就该用它。
Autotomy Expo Starter Pack 已经把这套 pattern 用在 network state、auth state 和 app lifecycle 上。一套 pattern,零 subscription bug,心态也会轻松很多。