Ловушка подписок через 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. Один паттерн, ноль багов в подписках и бесконечно больше спокойствия.