In React Native, how do you clean up an AppState listener and a setInterval inside useEffect, and what leaks if you forget?
answer
- the add call returns something
- subscription.remove() in the cleanup
- clearInterval with the saved id
- removeEventListener is gone
- closure keeps the screen's state alive
basics
~20 sKeep the subscription that AppState.addEventListener returns and call its remove() in the effect's cleanup, and clearInterval the saved interval id there too. Otherwise the handlers keep running and keep the unmounted screen's closures, state and data in memory.
solid answer
~40 sReact Native's event APIs return a subscription: `AppState.addEventListener('change', handler)` gives an `EventSubscription`, and so do `Keyboard.addListener`, `Dimensions.addEventListener` and `Linking.addEventListener`; `BackHandler.addEventListener` returns an object with `remove()` as well. I store it and call `subscription.remove()` in the `useEffect` cleanup — the old `removeEventListener`-style methods are gone, `BackHandler`'s last in 0.77. For timers I keep the id from `setInterval` and call `clearInterval(id)` in the same cleanup. If I forget, the emitter or timer still holds my handler, the handler's closure holds the screen's props, state setters and whatever data they reference, so none of it can be garbage-collected; each remount adds another live handler, and the interval keeps polling the network after the screen is gone.
code
tsx · 27 linesimport {useEffect, useState} from 'react';
import {AppState, Text} from 'react-native';
export function FareTicker({fetchFares}: {fetchFares: () => Promise<number>}) {
const [fare, setFare] = useState<number | null>(null);
useEffect(() => {
let active = AppState.currentState === 'active';
const subscription = AppState.addEventListener('change', next => {
active = next === 'active';
});
const id = setInterval(() => {
if (active) {
fetchFares().then(setFare);
}
}, 30000);
return () => {
subscription.remove();
clearInterval(id);
};
}, [fetchFares]);
return <Text>{fare === null ? 'Loading fares' : `From ${fare}`}</Text>;
}go deeper
Remember the shape: React Native's add calls return a subscription, you call remove() on it, and you clearInterval the saved id, all inside the useEffect cleanup.
Explain why a forgotten handler leaks: the emitter holds the closure, the closure holds state and data, and each remount adds another live handler.
Show how you would detect stacked handlers in a running app, and how you separate unmount cleanup from pausing work on unfocused but still-mounted screens.
Discuss team-level guardrails: shared hooks that own subscriptions, review checklists for effects that subscribe, and leak checks in repeated-navigation tests.
## The pattern React Native's APIs expect Most React Native modules that emit events follow one shape: **subscribing returns an object whose `remove()` method unsubscribes**. | API | Subscribe | Unsubscribe | |---|---|---| | `AppState` | `AppState.addEventListener('change', fn)` | `subscription.remove()` | | `Keyboard` | `Keyboard.addListener('keyboardDidShow', fn)` | `subscription.remove()` | | `Dimensions` | `Dimensions.addEventListener('change', fn)` | `subscription.remove()` | | `Linking` | `Linking.addEventListener('url', fn)` | `subscription.remove()` | | `BackHandler` (Android) | `BackHandler.addEventListener('hardwareBackPress', fn)` | `subscription.remove()` | | timers | `const id = setInterval(fn, ms)` | `clearInterval(id)` | The older `removeEventListener(type, handler)` methods on these modules were removed; `BackHandler.removeEventListener` was one of the last, gone in 0.77. Code that still calls them fails at run time, so the subscription object is the only way to unsubscribe. ## Where the cleanup goes In a function component the subscription is created in `useEffect` and removed in the function the effect returns. React runs that cleanup before the effect re-runs and when the component unmounts: 1. **Subscribe** inside the effect and keep the returned object in a local variable. 2. **Start timers** in the same effect and keep their ids. 3. **Return a cleanup** that calls `remove()` on every subscription and `clearInterval` / `clearTimeout` on every id. 4. **List the right dependencies**, so a changed handler input re-subscribes cleanly instead of stacking a second listener. ## What leaks when you forget Take a travel feed screen that refreshes fares every 30 seconds and pauses when the app goes to the background. - **The handler stays registered.** `AppState`'s emitter keeps a reference to the handler for as long as the subscription exists. - **The closure keeps the screen alive.** The handler is a **closure**: it references the state setter, props and any variables it uses — for example the array of feed items with their image URIs. Everything reachable from it stays reachable, so the garbage collector cannot reclaim it. - **Remounts multiply the damage.** Every time the user leaves and re-opens the feed, another handler and another interval are added; after ten visits there are ten intervals polling the network. - **Work continues after unmount.** The interval keeps firing, fetching fares and calling setters for a screen nobody can see, costing battery, data and JavaScript-thread time. ## Why garbage collection does not save you Hermes, React Native's JavaScript engine, frees objects that are **unreachable** — nothing live refers to them any more. Unmounting a component does not make its data unreachable if something long-lived still points at it: - the native-backed emitter behind `AppState` lives for the whole app session, and so does its list of handlers; - the timer registry keeps every uncleared interval callback; - each callback keeps its closure, and the closure keeps what it references. So the leak is not a collector bug. It is a reference you created and never released, and only `remove()` or `clearInterval` releases it. ## Common variations of the same leak - **`setTimeout` chains** that reschedule themselves: clear the latest id on cleanup. - **`requestAnimationFrame` loops**: cancel with `cancelAnimationFrame`. - **Module-level emitters or stores** you subscribe to from a screen: they hold your callback exactly like `AppState` does, and need the same unsubscribe. - **Native module event emitters** built on `NativeEventEmitter` return subscriptions with `remove()` too. ## A note on stack navigators Leaving a screen does not always unmount it. In a stack navigator, the previous screen stays mounted underneath the new one, so its effects — and its intervals — keep running until it is popped. Cleanup on unmount prevents leaks; pausing work while a screen is not focused is a separate concern handled with the navigator's focus events. ## How to catch it - Add a log or counter inside the handler, open and close the screen several times, and trigger the event: one call per event is correct, several means handlers are stacking. - Watch JavaScript heap growth across repeated visits, then compare heap snapshots to find the retaining handler. - Lint rules for hook dependencies help keep re-subscriptions correct, but they do not check that you called `remove()`. - Wrap recurring subscriptions in small custom hooks — one hook that subscribes to `AppState` and returns the current state, for example — so the `remove()` call is written once and every screen gets it for free. - In code review, treat any effect that calls an `add…` method or starts a timer without returning a cleanup as a bug until proven otherwise.
- Your React Native code calls AppState.removeEventListener('change', handler). What happens on 0.87, and what is the fix?That method no longer exists on `AppState`, so the call fails at run time instead of unsubscribing. Keep the `EventSubscription` returned by `AppState.addEventListener` and call `subscription.remove()` in the effect cleanup; the same applies to `Keyboard`, `Dimensions`, `Linking` and `BackHandler`.
- How can you tell from the app's behaviour that React Native listeners are stacking up?Log or count inside the handler, open and close the screen several times, then trigger the event. If one app-state change produces several log lines, or network requests multiply with each visit, handlers from earlier mounts are still registered. A JavaScript heap that grows with each visit points the same way.
saying these in an interview costs you the question
- Call AppState.removeEventListener with the same handler to unsubscribe
- Listeners are removed automatically when the component unmounts
- A forgotten interval only wastes CPU, it cannot retain memory
- Garbage collection frees the handler once the screen is gone
- Leaving a stack screen always unmounts it and runs its cleanup