Jebakan Subscription useEffect
Dulu saya menulis hook deteksi network seperti kebanyakan vibe coder: sebuah useEffect yang subscribe ke NetInfo, memperbarui state saat konektivitas berubah, lalu berharap semuanya baik-baik saja. Ia bekerja di development. Ia gagal di production.
Race condition. Memory leak. Stale closure. Tersangka yang biasa - yang tidak muncul di preview Cursor Anda tapi muncul di crash log Anda.
Inilah yang dihasilkan Claude Code saat Anda meminta deteksi network. Terlihat benar. Sebenarnya ini liability di 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
}
Masalahnya: tearing saat callback terpanggil sebelum effect berjalan. Memory leak saat unmount terjadi di tengah setup. Stale closures saat callback merujuk ke props dan dependency array salah. Duplikasi saat setiap komponen menulis boilerplate yang sama.
Inilah jenis bug yang membuat pengguna memberi rating satu bintang.
Saatnya useSyncExternalStore
React 18 memperkenalkan useSyncExternalStore khusus untuk kasus ini. Hook ini dirancang untuk membaca dari external store - apa pun yang memiliki fungsi subscribe dan fungsi getSnapshot. NetInfo adalah external store. Auth state Anda juga. Semua native module yang memancarkan event juga demikian.
Daripada menulis subscription useEffect yang rapuh, biarkan React menangani bagian yang sulit:
// 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
}
Adapter ini memiliki tepat satu native subscription. Saat NetInfo memancarkan event, adapter memperbarui snapshot internalnya dan memberi tahu semua listener. Hook-nya kemudian menghubungkan React ke store ini:
// 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
)
}
Selesai. React memiliki lifecycle subscription-nya. Service memiliki native subscription-nya. Hook ini hanya menjadi jembatan. Saat komponen mount, React melakukan subscribe. Saat unmount, React melakukan unsubscribe. Saat store berubah, React melakukan re-render. Anda tidak menulis logic itu. Anda tidak bisa salah menulisnya.
Satu Pattern untuk Setiap External Store
Ini scale dengan sangat baik. Auth, network, permissions, lifecycle - state apa pun dari luar React bisa memakai bentuk yang sama:
// 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
)
}
Sebelum useSyncExternalStore, saya menulis custom useEffect hook untuk setiap sumber data eksternal. Masing-masing sedikit berbeda. Masing-masing punya bug sendiri. Sekarang saya menulis satu pattern, menerapkannya ke mana-mana, dan membiarkan React menangani lifecycle subscription.
Jika Anda masih menulis useEffect untuk external subscription di aplikasi React Native hasil vibe coding Anda, Anda sedang bekerja lebih keras dari yang perlu dan menghasilkan kode yang lebih bug-prone dari yang seharusnya. Hook-nya sudah ada. Pakai itu.
Autotomy Expo Starter Pack memakai pattern ini untuk network state, auth state, dan app lifecycle. Satu pattern, nol bug subscription, dan ketenangan tanpa batas.