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:
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:
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.