skip to content

In a React Native multi-step insurance-quote screen, why can a BackHandler listener that is never removed trap the user at step one or leave the Android back button dead after the flow closes?

level: seniorimportance: should knowfreq 40%

answer

  1. module-level list outlives the screen
  2. effect without cleanup stacks handlers
  3. stale closure keeps an old step
  4. a stale true swallows the press
  5. cleanup returns subscription.remove()

basics

~20 s

BackHandler keeps handlers in one global list until remove() is called. An effect that subscribes without cleanup leaves stale handlers; one that captured an old step returns true and swallows the press — at step one, or everywhere once the flow unmounts.

solid answer

~50 s

BackHandler's list is module-level, so it outlives the wizard. If the effect subscribes on every `step` change but never returns `() => subscription.remove()`, each step leaves its own handler behind, each closing over the `step` value of its render. At step 0 the newest handler correctly returns `false`, but the walk continues to an older handler that still believes the user is on step 1: it calls `setStep(0)` and returns `true`, so the user cannot leave the flow. After the wizard unmounts, those stale handlers keep returning `true` on every other screen, so back appears dead and the closures keep the old component state reachable. The fix is one subscription per effect run with a cleanup, or one long-lived subscription that reads the current step from a ref. If the wizard sits in a navigator, the subscription should also follow screen focus.

code

tsx · 27 lines
tsx
import { useEffect, useRef, useState } from 'react';
import { BackHandler, Text, View } from 'react-native';

const STEPS = ['vehicle', 'driver', 'coverage', 'review'] as const;

export function QuoteWizard() {
  const [step, setStep] = useState(0);
  const stepRef = useRef(step);
  stepRef.current = step;

  useEffect(() => {
    const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
      if (stepRef.current === 0) {
        return false; // leave the flow: the navigator or Android handles it
      }
      setStep(stepRef.current - 1);
      return true;
    });
    return () => subscription.remove(); // one handler, removed on unmount
  }, []);

  return (
    <View>
      <Text>Step: {STEPS[step]}</Text>
    </View>
  );
}

go deeper

for a junior

Remember that every BackHandler subscription must be removed in the effect cleanup, or it outlives the screen that created it.

for a middle

Explain how the newest-first walk plus stale closures produce both symptoms, and why empty deps without a ref break the flow the other way.

for a senior

Trace a press through stacked stale handlers, fix it with cleanup or a ref, and note that screens inside a navigator need focus-scoped subscriptions, not only unmount cleanup.

for a principal

Decide who owns back in a multi-step flow — the wizard, the navigator, or a shared hook — and make the ownership reviewable so stale subscriptions cannot slip in.

## The scenario An insurance-quote flow has four steps — **vehicle, driver, coverage, review** — rendered by one screen that keeps `step` in state. Product wants Android back to go to the previous step and, on the first step, to leave the flow. QA files two bugs after a release: 1. On the **vehicle** step, pressing back does nothing. 2. After a user submits a quote and lands on the home screen, the **back button is dead** everywhere until the app restarts. Both come from the same line: a `BackHandler` subscription that is never removed. ## Why the list outlives the screen `BackHandler` stores handlers in **one array at module level** inside React Native. Unmounting a component does not touch that array; only `subscription.remove()` does. A handler also keeps its **closure** alive — the `step` value, the `setStep` function and whatever else it captured — so a forgotten handler is both a behaviour bug and a small memory leak. The dispatch rules make the leak visible: - handlers run **newest first**; - the first handler that returns `true` **stops** the walk; - if none returns `true`, React Native falls back to `exitApp()`. ## The buggy effect, traced ```tsx useEffect(() => { BackHandler.addEventListener('hardwareBackPress', () => { if (step > 0) { setStep(step - 1); return true; } return false; }); // no cleanup: every step change adds another handler }, [step]); ``` Walk the user from step 0 to step 3 and back: 1. Entering steps 0, 1, 2, 3 registers four handlers, capturing `step` = 0, 1, 2, 3. 2. Back at step 3: the newest handler (captured 3) sets step 2 and returns `true`. Fine — and the effect registers a fifth handler for step 2. 3. Back again: the newest (captured 2) sets step 1; another handler is added. Then step 0. 4. Back at step 0: the newest handler (captured 0) returns `false`, so the walk continues to an **older** handler that captured 1. It calls `setStep(0)` and returns `true`. **The press is swallowed — bug 1.** 5. The user submits instead; the screen unmounts. Every stale handler with `step > 0` still returns `true` on every press, anywhere in the app. **Bug 2.** ## The fixes | Approach | How it works | Watch out for | | --- | --- | --- | | Cleanup per effect run | Subscribe inside the effect with `[step]` deps and return `() => subscription.remove()` | Re-subscribes on every step change; cheap, and always current | | One subscription + ref | Subscribe once with `[]` deps; read the step from a `useRef` kept in sync | The ref must be updated on every change, or the handler reads stale data | | `[]` deps, no ref | Subscribe once and read `step` directly | **Broken the other way**: the closure sees step 0 forever, returns `false`, and back leaves the flow from step 3, losing the entered data | The first row is the clearest for a wizard: ```tsx useEffect(() => { const subscription = BackHandler.addEventListener('hardwareBackPress', () => { if (step === 0) return false; // let the navigator or Android handle it setStep(step - 1); return true; }); return () => subscription.remove(); }, [step]); ``` ## Production judgement - **Search for `addEventListener('hardwareBackPress'` without a matching `remove()`** during review; since `removeEventListener` was removed in 0.77, an effect with no returned cleanup is the tell. - **Do not rely on deduplication.** BackHandler ignores a second registration of the *same function object*, but an inline arrow is a new object on every effect run. - **Navigator screens stay mounted when you push another screen on top**, so an unmount-based cleanup is not enough there; the subscription has to follow focus. That is the navigation library's lifecycle, covered on its own. - **Reproduce on a real Android device** with the system back gesture as well as the button; since 0.81, apps targeting Android 16 get predictive back, and both paths reach the same JavaScript handlers. - **Treat "back does nothing" as a symptom of a stale `true`**, not of a missing handler: a missing handler exits the app, it never freezes it. ## What to say in an interview Name the mechanism (global list, newest first, stale closure returning `true`), trace one press to prove it, then give the fix — cleanup on every effect run, or a ref-backed single subscription — and mention focus-aware subscriptions for screens inside a navigator.

  • In React Native, why does subscribing once with empty effect deps and reading step directly make Android back leave the quote flow from any step?
    The handler closes over the first render, where `step` is 0. It returns `false` on every press, so the walk falls through to the navigator or to `exitApp()`, and the user leaves the flow and loses the entered data. A ref or re-subscribing on `step` changes fixes it.
  • Does BackHandler deduplicate handlers so that a missing cleanup is harmless?
    Only for the same function object: `addEventListener` skips a handler already in the list. An inline arrow inside an effect is a new object each run, so every run adds another entry and only `remove()` takes them off.

saying these in an interview costs you the question

  • BackHandler listeners are removed automatically when the component unmounts
  • A missing cleanup only wastes a little memory
  • Empty effect deps are always correct for BackHandler
  • If back does nothing, no listener is registered
  • BackHandler never registers the same function twice, so stacking cannot happen