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 はゼロ、そして心配事もほぼゼロです。