useEffect Subscription 陷阱
我以前寫 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 函式和 getSnapshot 函式的東西。NetInfo 是 external store。你的 auth state 也是。任何會發事件的 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 要做的事,只是把 React 接到這個 store 上:
// 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
)
}
就這樣。React 會接管 subscription lifecycle。service 負責 native subscription。hook 只負責做橋接。元件 mount 時,React 會訂閱。元件 unmount 時,React 會取消訂閱。store 改變時,React 會重新 render。這些邏輯你完全不用自己寫,所以你也不會把它寫錯。
每個 external store 都能用同一個 pattern
這個做法非常好擴展。auth、network、permissions、lifecycle,任何從 React 外部進來的 state 都能用同一個形狀:
// 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,到處套用,然後讓 React 自己處理 subscription lifecycle。
如果你在 Vibe-coded React Native app 裡,還在用 useEffect 來做 external subscription,那你就是在做比必要更多的工作,同時寫出比必要更容易出 bug 的程式碼。這個 hook 已經存在了。用它。
Autotomy Expo Starter Pack 已經把這個 pattern 套用在 network state、auth state 和 app lifecycle 上。單一 pattern,零 subscription bug,剩下的只有平靜。