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.
- Hardware back button returns to the screen you intended
- Edge-swipe gesture navigation produces the same behavior
- With a modal open, back closes only the modal — not the whole screen
- If you implemented a "press again to exit" toast pattern, the toast appears on the first press
- 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.