CORE JSC

International Technology Partnership

React Native

Fixing the Android Hardware Back Button That Exits a React Native App Instead of Closing a Modal or Sheet

A modal, bottom sheet, or custom drawer opens correctly and looks fine on screen, but pressing the Android hardware back button while it's open doesn't close it — it exits the entire app. The screen's own back navigation works perfectly elsewhere; the bug only shows up for UI state that isn't part of the navigator's stack.

Core JSC Team·October 12, 2026
React NativeAndroidBackHandlerNavigationMobile Development

The Problem

A screen opens a custom overlay — a bottom sheet, a full-screen modal built with Modal or an absolutely-positioned view, or a side drawer — and everything renders correctly. The user taps the Android hardware back button expecting it to close that overlay and return to the underlying screen, which is exactly what it does in every native Android app and in most of the rest of this React Native app. Instead, the app exits to the home screen entirely, as if the back press happened on the app's root screen. Reopening the app lands back on the same screen with the overlay gone, and there's no crash and no error in the logs — Android's default behavior for an unhandled back press is simply to close the activity, and that's exactly what's happening.

Why It Happens

Android's hardware back press has one default behavior, and anything that doesn't explicitly intercept it gets that default

Unlike iOS, which has no hardware back button, Android dispatches every hardware back press to the app and expects something in the app to handle it. React Navigation's native stack and drawer navigators register their own listener and consume the event when there's a screen to pop, but that listener only knows about navigator state — screens, params, stack depth. It has no idea that a useState flag is currently rendering a modal on top of the current screen, because that's local component state, not navigation state. When the user presses back, React Navigation looks at its own stack, decides there's nothing for it to pop in this case (or the modal screen isn't a stack entry at all), and does nothing — so the press falls through to Android's OS-level default, which exits the activity.

Custom overlays built outside the navigator are invisible to anything that only handles navigation-level back presses

A bottom sheet or modal implemented as a sibling view toggled by local state, rather than as a pushed screen in the navigator, is architecturally simpler for a lot of use cases — no route params, no navigator typing, just a boolean. But it also means there is no navigator-level hook for "back press while this is open," because as far as the navigator is concerned, nothing changed; the same screen is still on top of the stack. The overlay has to register its own interest in the back press independently, and if nobody wrote that code, nobody is listening for it at all.

A BackHandler listener that was added once and never cleaned up, or that returns the wrong value, causes problems even when one does exist

BackHandler.addEventListener("hardwareBackPress", callback) expects callback to return true when it has handled the press (which stops the event from propagating further) and false or undefined when it hasn't (which lets the next listener, or the OS default, handle it). A callback that closes the modal but forgets the return true — or a stale listener left behind from a previous mount because the cleanup function was never actually called — produces exactly this symptom: the modal might even flicker shut for a frame while the app still exits, because the event keeps propagating past a handler that "handled" it without declaring that it did.

The Fix

1. Register a BackHandler listener whenever the overlay is open, and return true only when it actually consumes the press

import { useEffect } from "react";
import { BackHandler } from "react-native";

function useBackHandlerClose(isOpen, onClose) {
  useEffect(() => {
    if (!isOpen) return;

    const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
      onClose();
      return true; // we handled it — stop here, don't exit the app
    });

    return () => subscription.remove();
  }, [isOpen, onClose]);
}

// Inside the component rendering the modal:
useBackHandlerClose(isModalVisible, () => setIsModalVisible(false));

The listener is only attached while the overlay is actually open, and it unconditionally returns true because by the time it runs, it has already closed the overlay — there's nothing left for a parent listener or the OS default to do. Gating registration on isOpen also means the hardware back button falls through to its normal behavior (navigating between screens) the rest of the time.

2. Use subscription.remove() from the listener's return value, not the deprecated removeEventListener call

// Deprecated and removed in newer React Native versions — don't use this:
// BackHandler.removeEventListener("hardwareBackPress", callback);

// Correct: addEventListener itself returns the subscription to clean up
const subscription = BackHandler.addEventListener("hardwareBackPress", callback);
return () => subscription.remove();

Older examples circulating online call a separate BackHandler.removeEventListener with the original callback reference, which was removed from newer React Native versions entirely. Relying on it either throws or silently no-ops depending on the version, leaving the old listener attached and causing exactly the "listener that still fires after the component using it is gone" bug described above.

3. For a drawer or sheet built with a library, check whether it already exposes a prop for this before writing a custom listener

// Many bottom-sheet and drawer libraries already wire this up —
// check for a prop like this before adding a second, competing listener:
 setIsSheetVisible(false)}
/>

Writing a second BackHandler listener on top of one a UI library already registers internally leads to two handlers racing for the same event, which can produce inconsistent results (sometimes the sheet closes, sometimes the app still exits) depending on registration order. Checking the library's own back-press handling option first avoids the conflict entirely.

4. On a bottom-tab or drawer root screen, explicitly decide what back should do instead of leaving Android's default exit behavior unexamined

useEffect(() => {
  const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
    if (navigation.canGoBack()) {
      return false; // let React Navigation handle it normally
    }
    // On the true root screen, decide deliberately — e.g. a "press back again to exit" prompt
    showExitConfirmationToast();
    return true;
  });
  return () => subscription.remove();
}, [navigation]);

Checking navigation.canGoBack() before deciding what a listener should do keeps it from interfering with ordinary in-stack navigation, while still giving the root screen a deliberate, intentional behavior — a confirmation prompt, a double-press-to-exit pattern — instead of whatever Android's unexamined default happens to do.

Why This Works

All of these fixes share the same underlying principle: the hardware back button has exactly one default behavior (exit the activity), and anything that should instead close an overlay, a sheet, or a drawer has to explicitly register that it's handling the press and say so by returning true. Scoping the listener to only be active while the overlay is open keeps it from interfering with normal navigation; cleaning it up with the subscription object avoids the deprecated API's failure modes; deferring to a library's own built-in handling avoids two listeners fighting over the same event; and checking canGoBack() on a root screen turns an unexamined default into a deliberate choice.

Conclusion

An Android back press that exits the app instead of closing a modal isn't a React Navigation bug — it's Android falling through to its default "close the activity" behavior because nothing in the component tree told it otherwise. Register a BackHandler listener that's only active while the custom overlay is open, return true from it only when it actually closes something, clean it up with subscription.remove() rather than the deprecated removal API, and on a true root screen, decide explicitly what back should do rather than leaving it to Android's default.