Memory Leaks

Memory Leaks and Listener Cleanup

JavaScript frees what nothing references, so a leak is a reference that outlives its purpose: a timer, a subscription or a listener registered in an effect and never removed. The component unmounts, but its callback, and everything the callback closes over, stays reachable from the native event source. The demo's Poller starts a timer and an AppState listener, and releases them only when clean is true:

src/demos/Perf.tsx: an effect with and without cleanup (excerpt)TSX
function Poller({ clean }: { clean: boolean }) {
  useEffect(() => {
    const n = nextTimer++;
    const timer = setInterval(() => console.log(`tick from timer ${n}`), 2000);
    const sub = AppState.addEventListener('change',
      (s) => console.log(`timer ${n} sees ${s}`));
    if (!clean) return;                                  // the leak: nothing is released
    return () => { clearInterval(timer); sub.remove(); };
  }, [clean]);

"Count listeners" logs DeviceEventEmitter.listenerCount('appStateDidChange'). Mounting and unmounting the leaky poller twice, then the clean one twice:

Output of 245
 LOG  appStateDidChange listeners: 5
...
 LOG  appStateDidChange listeners: 7
 LOG  tick from timer 2
 LOG  tick from timer 1
...
 LOG  appStateDidChange listeners: 7
 LOG  tick from timer 2
 LOG  tick from timer 1

The leaky pollers left two listeners behind (5 became 7), and their timers kept ticking with nothing on screen until the app restarted. The clean pollers, timers 3 and 4, stopped when unmounted, and the count stayed at 7. Every addEventListener, setInterval, subscribe and watchPositionAsync in an effect needs its cleanup returned from the same effect. To find leaks that slipped through, compare two heap snapshots in the DevTools Memory panel, taken before and after opening and closing a screen several times.