La trampa de las suscripciones con useEffect

Yo solía escribir hooks de detección de red como lo hacen la mayoría de los vibe coders: un useEffect que se suscribe a NetInfo, actualiza el estado cuando cambia la conectividad y espera que todo salga bien. Funcionaba en desarrollo. Fallaba en producción.

Race conditions. Memory leaks. Stale closures. Los sospechosos habituales que no aparecen en la preview de Cursor pero sí en tus crash logs.

Esto es lo que Claude Code genera cuando le pides detección de red. Parece correcto. En realidad, es una responsabilidad de producción:

// 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
}

Los problemas: tearing cuando el callback se dispara antes de que el efecto corra. Memory leaks cuando el desmontaje ocurre durante el setup. Stale closures cuando el callback referencia props y el array de dependencias está mal. Duplicación cuando cada componente escribe el mismo boilerplate.

Estos son los bugs que hacen que los usuarios dejen reseñas de una estrella.

Entra useSyncExternalStore

React 18 introdujo useSyncExternalStore específicamente para esto. Está diseñado para leer desde external stores: cualquier cosa con una función subscribe y una función getSnapshot. NetInfo es un external store. También lo es tu estado de auth. También cualquier módulo nativo que emita eventos.

En vez de escribir suscripciones frágiles con useEffect, deja que React se encargue de las partes difíciles:

// 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
}

El adaptador posee exactamente una suscripción nativa. Cuando NetInfo emite, actualiza su snapshot interno y notifica a todos los listeners. El hook conecta React con este 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
  )
}

Eso es todo. React se encarga del ciclo de vida de la suscripción. El servicio se encarga de la suscripción nativa. El hook solo hace de puente. Cuando el componente se monta, React se suscribe. Cuando se desmonta, React se desuscribe. Cuando el store cambia, React vuelve a renderizar. Tú no escribes nada de esa lógica. No puedes equivocarte.

Un patrón para cualquier external store

Esto escala de maravilla. Auth, network, permisos, lifecycle: cualquier estado que venga de fuera de React puede usar la misma forma:

// 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
  )
}

Antes de useSyncExternalStore, escribía hooks personalizados con useEffect para cada fuente de datos externa. Cada uno era ligeramente distinto. Cada uno tenía sus propios bugs. Ahora escribo un patrón, lo aplico en todas partes y dejo que React gestione el ciclo de vida de la suscripción.

Si todavía escribes useEffect para suscripciones externas en tu app React Native hecha con vibe coding, estás trabajando más de lo necesario y produciendo código con más bugs de los que hace falta. El hook existe. Úsalo.

El Autotomy Expo Starter Pack usa este patrón para el estado de red, el estado de auth y el lifecycle de la app. Un patrón, cero bugs de suscripción y una paz mental infinita.