Le piège des subscriptions useEffect

Avant, j’écrivais mes hooks de détection réseau comme la plupart des vibe coders : un useEffect qui s’abonne à NetInfo, met l’état à jour quand la connectivité change, puis croise les doigts. Ça marchait en développement. Ça échouait en production.

Race conditions. Memory leaks. Stale closures. Les suspects habituels qui n’apparaissent pas dans votre preview Cursor, mais qui finissent dans vos crash logs.

Voici ce que Claude Code génère quand vous demandez une détection réseau. Ça a l’air correct. En réalité, c’est un risque de production :

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

Les problèmes : tearing quand le callback se déclenche avant que l’effet ne s’exécute. Memory leaks quand le démontage arrive pendant l’initialisation. Stale closures quand le callback référence des props et que le tableau de dépendances est faux. Duplication quand chaque composant réécrit le même boilerplate.

Ce sont ces bugs-là qui vous valent des avis une étoile.

useSyncExternalStore entre en scène

React 18 a introduit useSyncExternalStore précisément pour ça. Il est conçu pour lire des stores externes, c’est-à-dire n’importe quoi qui expose une fonction subscribe et une fonction getSnapshot. NetInfo est un store externe. Votre état d’auth aussi. Tout module natif qui émet des événements aussi.

Au lieu d’écrire des subscriptions useEffect fragiles, laissez React gérer les parties difficiles :

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

L’adapter possède exactement une subscription native. Quand NetInfo émet, il met à jour son snapshot interne puis notifie tous les listeners. Le hook relie React à ce 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
  )
}

Et c’est tout. React possède le cycle de vie de la subscription. Le service possède la subscription native. Le hook ne fait que les relier. Quand le composant monte, React s’abonne. Quand il se démonte, React se désabonne. Quand le store change, React rerender. Vous n’écrivez aucune de ces logiques. Vous ne pouvez pas vous tromper.

Un pattern unique pour tous les stores externes

Ce pattern passe très bien à l’échelle. Auth, réseau, permissions, cycle de vie, tout état provenant de l’extérieur de React peut suivre la même forme :

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

Avant useSyncExternalStore, j’écrivais des hooks useEffect custom pour chaque source de données externe. Chacun était légèrement différent. Chacun avait ses propres bugs. Maintenant, j’écris un seul pattern, je l’applique partout, et je laisse React gérer le cycle de vie des subscriptions.

Si vous écrivez encore des useEffect pour des subscriptions externes dans votre app React Native vibe codée, vous travaillez plus que nécessaire et vous produisez un code plus bogué qu’il ne devrait l’être. Le hook existe. Utilisez-le.

L’Autotomy Expo Starter Pack utilise ce pattern pour l’état réseau, l’état d’auth et le cycle de vie de l’app. Un seul pattern, zéro bug de subscription, une tranquillité d’esprit infinie.