The useEffect Subscription Trap
I used to write network detection hooks the way most vibe coders do: a useEffect that subscribes to NetInfo, updates state when connectivity changes, and hopes for the best. It worked in development. It failed in production.
Race conditions. Memory leaks. Stale closures. The usual suspects that don’t show up in your Cursor preview but show up in your crash logs.
Here’s what Claude Code generates when you ask for network detection. It looks correct. It’s actually a 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
}
The problems: tearing when the callback fires before the effect runs. Memory leaks when unmount happens during setup. Stale closures when the callback references props and the dependency array is wrong. Duplication when every component writes the same boilerplate.
These are the bugs that make users leave one-star reviews.
Enter useSyncExternalStore
React 18 introduced useSyncExternalStore specifically for this. It’s designed to read from external stores — anything with a subscribe function and a getSnapshot function. NetInfo is an external store. So is your auth state. So is any native module that emits events.
Instead of writing fragile useEffect subscriptions, let React handle the hard parts:
// 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
}
The adapter owns exactly one native subscription. When NetInfo fires, it updates its internal snapshot and notifies all listeners. The hook connects React to this 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
)
}
That’s it. React owns the subscription lifecycle. The service owns the native subscription. The hook just bridges them. When the component mounts, React subscribes. When it unmounts, React unsubscribes. When the store changes, React re-renders. You don’t write any of that logic. You can’t get it wrong.
One Pattern for Every External Store
This scales beautifully. Auth, network, permissions, lifecycle — any state from outside React can use the same 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
)
}
Before useSyncExternalStore, I wrote custom useEffect hooks for every external data source. Each one was slightly different. Each one had its own bugs. Now I write one pattern, apply it everywhere, and let React handle the subscription lifecycle.
If you’re still writing useEffect for external subscriptions in your vibe-coded React Native app, you’re working harder than you need to and producing buggier code than necessary. The hook exists. Use it.
The Autotomy Expo Starter Pack uses this pattern for network state, auth state, and app lifecycle. One pattern, zero subscription bugs, infinite peace of mind.