RORK LABJP
TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the roundTOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude CodeSIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App StoreMAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessageNATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core MLSEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Articles/Dev Tools
Dev Tools/2026-04-27Intermediate

Why the Android Back Button Stops Working in Your Rork App — A BackHandler Field Guide

When testers tap the Android back button, your Rork app exits unexpectedly, jumps to the wrong screen, or refuses to dismiss a modal. Here are the five most common BackHandler pitfalls in Expo Router projects, with working code for each.

Rork515Android43BackHandlerExpo Router5Troubleshooting38

You shipped a Rork-built Android app to internal testers, and within a day the same complaint keeps landing in your inbox: "The back button just kills the app." If you've been developing primarily on iOS, this is one of the most predictable blind spots — iOS has no hardware back button, so nothing in your day-to-day workflow surfaces the bug.

On Android, the back gesture (or hardware button) is the most-pressed interaction in the entire OS. An app that handles it poorly gets one-star reviews regardless of how good the rest of it is. And because Rork generates code optimized for cross-platform behavior, the default scaffolding doesn't always anticipate every Android-specific edge case.

This guide walks through the five most common reasons the back button misbehaves in Rork-generated React Native + Expo Router apps, with the actual code that fixes each one.

Three things to check before you start

Before diving into code, isolate the problem so you don't end up debugging the wrong layer:

  • Does it fail on every screen, or only specific ones?
  • Does it only fail while a modal, bottom sheet, or full-screen overlay is open?
  • Does the behavior differ between Development Build and Production Build?

If only one screen breaks, your BackHandler registration is almost certainly out of sync with the Expo Router stack. If only modals break, a third-party library is probably stealing the event. If only Production Build breaks, you might be hitting a minification or ProGuard issue. For broader Expo Router issues, the Rork × Expo Router navigation troubleshooting guide is a useful companion.

Cause 1: Registering with useEffect and forgetting cleanup

This is the bug I see most often, and it's deceptive because the first run looks fine.

// ❌ Broken
import { BackHandler } from 'react-native';
 
export default function ChatScreen() {
  useEffect(() => {
    BackHandler.addEventListener('hardwareBackPress', () => {
      return true;
    });
    // Missing removeEventListener
  }, []);
}

Every time the user navigates away and back, another listener is registered. Eventually you have five or more listeners stomping on each other, and the back button stops doing anything sensible. In the worst case, the app freezes for a moment because every listener runs synchronously on the JS thread.

// ✅ Correct
useEffect(() => {
  const sub = BackHandler.addEventListener('hardwareBackPress', () => {
    return true; // returning true consumes the event and blocks the default
  });
  return () => sub.remove();
}, []);

Always assign the return value of addEventListener and call .remove() in the cleanup function. This is the single most important rule when working with BackHandler. If you only remember one thing from this guide, make it this.

Cause 2: Not using useFocusEffect

If you only want a screen-specific behavior — say, "show a confirmation dialog before leaving the editor" — useEffect is the wrong hook. Use useFocusEffect from expo-router.

import { useFocusEffect } from 'expo-router';
import { useCallback } from 'react';
 
useFocusEffect(
  useCallback(() => {
    const sub = BackHandler.addEventListener('hardwareBackPress', () => {
      Alert.alert('Unsaved changes', 'Are you sure you want to leave?', [
        { text: 'Cancel', style: 'cancel' },
        { text: 'Leave', onPress: () => router.back() },
      ]);
      return true;
    });
    return () => sub.remove();
  }, [])
);

useEffect keeps the listener alive until the component unmounts, which means your "unsaved changes" prompt will fire on completely unrelated screens that happen to be in the same stack. useFocusEffect runs cleanup the moment the screen loses focus, giving each screen its own clean back-button contract. The useCallback wrapper is non-optional — without it, the focus effect will reattach the listener on every render and you'll be back to the multi-listener bug from Cause 1.

Cause 3: Forgetting to return true on the right branch

A BackHandler listener returning true consumes the event. Returning false (or undefined) lets the OS run its default behavior — pop the navigation stack, or exit the app if there's nothing to pop.

// ❌ Subtle bug
BackHandler.addEventListener('hardwareBackPress', () => {
  if (modalVisible) {
    setModalVisible(false);
    // Forgot to return true → modal closes AND app exits
  }
  return false;
});
 
// ✅ Fixed
BackHandler.addEventListener('hardwareBackPress', () => {
  if (modalVisible) {
    setModalVisible(false);
    return true; // critical
  }
  return false; // when no modal is open, let the default handler run
});

Every weird "modal closes but the app also dies" report I've debugged has come down to this. Make return explicit on every branch, and consider adding an ESLint rule that forbids implicit returns in BackHandler callbacks if your team is large enough that this kind of slip will recur.

Cause 4: Modals and bottom sheets stealing the event

Libraries like react-native-modal and @gorhom/bottom-sheet register their own BackHandler listeners internally. The contract is that listeners registered later run first, so if your screen's listener was registered before the modal mounted, you get one of two failure modes: the modal won't dismiss, or the modal dismisses but also pops the navigation stack.

Two fixes that work in practice:

  • Temporarily detach the screen's listener while the modal is open, and reattach it on dismissal
  • Let the library's built-in dismiss behavior (onBackdropPress, enableHandlePanningGesture, dismissKeyboard) handle the close instead of writing your own

The second option is almost always cleaner. Most modal libraries get this right by default, and overriding their handler is a smell that you're fighting the framework. If you're stuck on more general modal interaction problems, the Rork modal-not-closing fix guide covers the related ground.

Cause 5: Expo Router stack shape doesn't match expectations

Nested (tabs) groups and stacked Stack.Screen declarations sometimes pop a different stack than you expect. The fastest debugging trick is logging router.canGoBack() from the suspect screen.

import { router } from 'expo-router';
 
useEffect(() => {
  console.log('canGoBack:', router.canGoBack());
}, []);

If canGoBack() returns false on a screen and the user presses back, the OS will exit the app — which is the documented behavior, but probably not what your design intended. The classic case is opening a modal from a (tabs) root screen and assuming the back button will close the modal because "there's a screen behind it." There isn't, from the router's perspective; the modal lives outside the navigation stack.

The fix is usually to convert the modal into a presentation: 'modal' screen so it becomes part of the navigable stack. For the broader iOS-vs-Android divergence pattern, the Rork iOS/Android behavior difference guide is worth a read.

Verify on a real device, in this order

After fixing, walk through this checklist on a physical Android 12+ device. Emulators miss several of these because gesture navigation behaves differently when there's no real touch hardware.

  1. Hardware back button returns to the screen you intended
  2. Edge-swipe gesture navigation produces the same behavior
  3. With a modal open, back closes only the modal — not the whole screen
  4. If you implemented a "press again to exit" toast pattern, the toast appears on the first press
  5. The behavior survives a hot reload and a cold start

Steps 3, 4, and 5 sometimes only behave correctly in Production Build. Run eas build --profile production and verify on the resulting binary before declaring victory. I've shipped at least two updates where the dev build looked clean but the production binary regressed because of a different release-only optimization.

The back button is the foundation of Android UX

Android's back gesture is pressed many times more often than iOS's top-left chevron. If yours doesn't behave predictably, users will give up on your app no matter how polished the rest of it is. The Play Store reviews that mention "back button" are almost always one-star.

The single most useful thing you can do today is grep your codebase for BackHandler, then rewrite every occurrence using the three-piece formula in this article: useFocusEffect for screen-scoped logic, store the subscription returned by addEventListener, and make every return value explicit. The improvement in perceived stability is immediate, and you will probably catch a couple of bugs you did not know you had.

If you want to go one step further, add a small wrapper hook like useScreenBackHandler(handler) that bundles the focus effect, the subscription cleanup, and the explicit return contract into a single call. New screens become safer by default, and code review for back-button logic becomes a one-line check rather than a careful audit. Treating the Android back button as a first-class part of your screen API — instead of an afterthought handled per-screen — is the difference between an app that feels native on Android and one that constantly fights the platform.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Dev Tools2026-07-15
A Yellow Warning in Dev, a Crash on Resume — Functions in Route Params
Rork generated navigation code that put a function and a Date into route params. In development it was only a warning. After the OS reclaimed the process, resuming the app crashed with params.onFavorite is not a function. Here is the cause and the fix.
Dev Tools2026-05-29
Diagnosing 'Network request failed' That Only Hits Android Emulator in Rork
Your fetch returns fine in the iOS simulator but throws 'Network request failed' the moment you switch to Android. Here is the diagnosis order I use to separate localhost, cleartext, certificate, and proxy issues, with code that actually compiles.
Dev Tools2026-05-19
Fixing 'Row too big to fit into CursorWindow' on Android When AsyncStorage Holds Too Much in Rork
When a React Native app generated with Rork stores large JSON or image metadata in AsyncStorage, Android can throw a Row too big to fit into CursorWindow exception. Here are the practical fixes — MMKV migration, chunked keys, payload trimming, and compression — explained from real wallpaper-app experience.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →