Ловушка подписок через useEffect
Раньше я писал hooks для отслеживания сети так же, как это делает большинство vibe coders: useEffect, который подписывается на NetInfo, обновляет state при изменении connectivity и надеется на лучшее. В dev это работало. В production - нет.
Race conditions. Memory leaks. Stale closures. Весь привычный набор проблем, которые не видно в превью Cursor, но отлично видно в crash logs.
Вот что генерирует 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
}
Проблемы здесь такие: tearing, когда callback срабатывает до того, как effect успел установиться. Memory leaks, когда unmount случается во время setup. Stale closures, когда callback читает props, а dependency array составлен неверно. Duplication, когда каждый компонент пишет один и тот же boilerplate.
Именно из-за таких багов пользователи и ставят одну звезду.
Встречайте useSyncExternalStore
React 18 специально добавил для этого useSyncExternalStore. Этот hook предназначен для чтения данных из external stores - всего, у чего есть функция subscribe и функция getSnapshot. NetInfo - это external store. Ваш auth state - тоже. Любой native module, который шлёт события, тоже подходит.
Вместо того чтобы писать хрупкие подписки на useEffect, дайте 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
}
Адаптер владеет ровно одной нативной подпиской. Когда NetInfo срабатывает, он обновляет внутренний snapshot и уведомляет всех listeners. 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 сам управляет lifecycle подписки. Сервис сам управляет нативной подпиской. Hook только соединяет одно с другим. Когда компонент монтируется, React подписывается. Когда размонтируется, React отписывается. Когда store меняется, React делает re-render. Вы не пишете этот код сами. И, значит, не можете ошибиться.
Один паттерн для любого external store
Этот подход прекрасно масштабируется. Auth, network, permissions, lifecycle - любое состояние, которое живёт вне React, может использовать одну и ту же форму:
// 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 я писал кастомные useEffect hooks для каждого внешнего источника данных. Каждый был чуть-чуть другим. У каждого были свои баги. Теперь я пишу один паттерн, применяю его везде и позволяю React управлять lifecycle подписок.
Если вы всё ещё пишете useEffect для внешних подписок в своём vibe-coded React Native-приложении, вы работаете больше, чем нужно, и получаете больше багов, чем должны. Hook уже существует. Используйте его.
Autotomy Expo Starter Pack использует этот паттерн для network state, auth state и app lifecycle. Один паттерн, ноль багов в подписках и бесконечно больше спокойствия.