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,剩下的只有平靜。