useEffect Subscription Trap

以前の私は network detection hook を、多くの vibe coder と同じ書き方で作っていました。useEffect で NetInfo に subscribe し、connectivity が変わったら state を更新し、あとはうまくいくことを祈る。development では動く。でも production では壊れる。

race condition。memory leak。stale closure。Cursor preview では見えず、crash log でだけ顔を出す、いつもの面々です。

network detection を頼んだときに Claude Code が生成するのはこんなコードです。正しそうに見える。でも 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
}

問題はこうです。effect が走る前に callback が発火したときの tearing。setup の途中で unmount されたときの memory leak。callback が props を参照しているのに dependency array が間違っているときの stale closure。そしてどの component も同じ boilerplate を書き始める duplication

こういうバグが one-star review を生みます。

useSyncExternalStore の出番

React 18 はまさにこの用途のために useSyncExternalStore を導入しました。これは external store を読むための hook です。subscribe function と getSnapshot function を持つものなら何でも対象になる。NetInfo も external store。auth state もそう。event を emit する native module も同じです。

fragile な 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 を 1 つだけ持つ。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 は bridge になるだけ。component が mount したら React が subscribe し、unmount したら unsubscribe する。store が変わったら React が再 render する。そのロジックを自分で書かなくていい。書かないから間違えない。

すべての external store に使える 1 つの pattern

この形はとてもよく scale します。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 ごとに custom useEffect hook を書いていました。どれも少しずつ違う。どれも固有のバグを持つ。今は 1 つの pattern を書いて、それをどこにでも適用し、subscription lifecycle は React に任せます。

もし今も Vibe-coded React Native app で external subscription に useEffect を書いているなら、必要以上に大変なやり方を選び、必要以上にバグの多いコードを生んでいます。hook はもうあります。使うべきです。

Autotomy Expo Starter Pack では、この pattern を network state、auth state、app lifecycle に使っています。1 つの pattern、subscription bug はゼロ、そして心配事もほぼゼロです。