Rork で作った Android アプリを Internal Testing で配布したところ、テスターから「戻るボタンを押したらアプリが落ちる(終了する)」というフィードバックが立て続けに届く——これは Rork で iOS 中心に開発している方が、Android で初めて出くわす盲点のひとつです。
iOS にはハードウェアの戻るボタンが存在しないため、テストでは気づきません。Android ではほとんどのユーザーが画面下のジェスチャーまたはハードウェアボタンで「戻る」を多用します。ここが正しく動かないアプリは、レビューで容赦なく星1を付けられます。
ここではRork が生成した React Native + Expo Router 構成のアプリで、Android の戻るボタンが効かない・想定外に動く5つのパターンと、それぞれの直し方を実コード付きで整理します。
まず確認したい3つのこと
トラブルシューティングに入る前に、以下を切り分けます。
- アプリ全体で効かないのか、特定の画面だけか
- モーダル表示中だけ効かないのか
- Development Build と Production Build で挙動が違うか
特に「特定の画面だけ効かない」ケースは、Expo Router のスタック構成と BackHandler の登録タイミングがずれているサインです。Expo Router 全体の挙動については Rork × Expo Routerで起きる画面遷移エラーの原因と解決策 も合わせて読むと理解が深まります。
原因1: useEffectで登録してクリーンアップを忘れている
最もよく見かけるのがこのパターンです。
// ❌ 効かないコード
import { BackHandler } from 'react-native';
export default function ChatScreen() {
useEffect(() => {
BackHandler.addEventListener('hardwareBackPress', () => {
// 確認ダイアログを出したい
return true;
});
// ← removeEventListener を呼んでいない
}, []);
}このコードは初回は動いているように見えますが、画面を離れて戻ってきたあと、リスナーが多重登録されて挙動が壊れます。最終的にアプリ全体でハードウェア戻るが無効化されることもあります。
// ✅ 正しいコード
useEffect(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
return true; // true を返すと「戻る」を消費し、デフォルト挙動を止める
});
return () => sub.remove();
}, []);返り値の sub.remove() を必ず cleanup で呼ぶこと。これが BackHandler を扱う上での最重要ルールです。
原因2: useFocusEffectを使っていない
スタックナビゲーション内の特定画面でだけ戻るボタンの挙動を変えたい場合、useEffect ではなく useFocusEffect を使います。
import { useFocusEffect } from 'expo-router';
import { useCallback } from 'react';
useFocusEffect(
useCallback(() => {
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
// この画面がアクティブな間だけ実行される
Alert.alert('保存していない変更があります', '本当に戻りますか?', [
{ text: 'キャンセル', style: 'cancel' },
{ text: '戻る', onPress: () => router.back() },
]);
return true;
});
return () => sub.remove();
}, [])
);useEffect で登録すると、画面がアンマウントされるまでリスナーが残り続けるため、別の画面に遷移しても確認ダイアログが出るようになってしまいます。useFocusEffect はフォーカスを失った瞬間に cleanup を走らせるので、画面ごとに独立した戻る挙動を組めます。
原因3: trueを返していない(または条件分岐の漏れ)
BackHandler のリスナーは true を返すとデフォルト挙動(前画面に戻る、または最上位ならアプリ終了)を抑止します。false または undefined を返すとデフォルト挙動が走ります。
// ❌ うっかりミス
BackHandler.addEventListener('hardwareBackPress', () => {
if (modalVisible) {
setModalVisible(false);
// ← return true を書き忘れると、モーダルを閉じつつアプリも終了する
}
return false;
});
// ✅ 修正版
BackHandler.addEventListener('hardwareBackPress', () => {
if (modalVisible) {
setModalVisible(false);
return true; // ここが命
}
return false; // モーダルが開いていないときはデフォルト挙動に任せる
});「モーダルを閉じてアプリも終了する」という意味不明な挙動の原因はこれです。条件分岐ごとに return を明示する癖をつけてください。
原因4: モーダルやBottomSheetがハードウェア戻るを横取りしている
react-native-modal や @gorhom/bottom-sheet などのライブラリは、内部で独自の BackHandler リスナーを登録しています。優先順位は「あとから登録したリスナーが先に呼ばれる」仕様のため、画面側のリスナーが先に登録されていると、モーダルを閉じる動作が動かなかったり、逆にモーダルが閉じる前に画面遷移してしまったりします。
対策は以下の2つです。
- モーダルを開くタイミングで画面側のリスナーを一時的に外す
- モーダル内で完結する戻る処理は、ライブラリ標準の
onBackdropPressやenableHandlePanningGestureに任せる
複雑なモーダル制御で詰まった場合は Rorkでモーダルが閉じない・タップが効かない問題を直す手順 も参考にしてください。
原因5: Expo Routerのスタック構成が想定と違う
Expo Router の (tabs) グループや Stack.Screen の入れ子構成によっては、戻るボタンが期待とは別のスタックを巻き戻すことがあります。
確認のコツは、画面コンポーネント内で router.canGoBack() の戻り値をデバッグログに出すことです。
import { router } from 'expo-router';
useEffect(() => {
console.log('canGoBack:', router.canGoBack());
}, []);canGoBack() が false を返している画面でハードウェア戻るを押すと、デフォルトでアプリが終了します。これは仕様通りの動きで、Modal 表示中以外でこの画面に到達してしまう設計を見直す必要があります。(tabs) のルート画面でモーダルを開いている場合などによく起きます。
iOS と Android で挙動が分かれる現象全般については RorkでiOSとAndroidで挙動が違うときの切り分けと修正 に体系的にまとめてあります。
修正前後を実機で確認する
修正したら、必ず以下の順で実機検証します。エミュレータでは再現しないバグも多いので、最低でも Android 12 以上の実機で確認してください。
- ハードウェア戻るボタンで意図した画面に戻るか
- ジェスチャーナビゲーション(画面端スワイプ)で同じ挙動になるか
- モーダルを開いた状態で戻るを押したとき、モーダルだけが閉じるか
- ダブルタップで「もう一度押すと終了」のパターンを実装しているなら、トーストが正しく出るか
3 と 4 は Production Build でしか正しく挙動しないことがあるので、eas build --profile production でビルドしてから確認してください。
戻るボタンの設計はAndroid体験の土台
Android の「戻る」は、iOS の左上の < ボタンよりもはるかに頻繁に押されるインタラクションです。ここが期待通りに動かないアプリは、機能の素晴らしさに関係なく、ユーザーから「使いづらい」と即断されます。
今日まずできる一歩は、自分のアプリで BackHandler を使っている画面をすべて洗い出し、useFocusEffect + sub.remove() + 明示的な return true/false の3点セットで書き直すことです。これだけで体感の安定感が大きく変わります。