Beautiful HD Wallpapers の Android 版を v2.1.0 にアップデートしたとき、課金画面(PaywallDialog)とレビュー誘導ダイアログ(ReviewInductionDialog)が重なって表示されるバグに遭遇しました。どちらかのモーダルを操作しようとしても、もう一方が前面にある状態になり、ユーザーが操作不能になります。
2014年から12年以上、累計5,000万DLを超えるアプリを個人で開発してきた経験の中で、このパターンは一番気づきにくいバグの一つだと感じています。React Native(Rork が生成するコードもこれをベースにしています)では、複数の非同期イベントが同時にモーダル表示をトリガーする状況が意外と簡単に起きます。
なぜモーダルは重なるのか
Rork が生成する React Native アプリでは、モーダルの表示制御は通常こんな形のローカル state で管理されます。
const [showPaywall, setShowPaywall] = useState(false);
const [showReviewPrompt, setShowReviewPrompt] = useState(false);
const [showRewardedAd, setShowRewardedAd] = useState(false);それぞれのモーダルを表示するトリガーが独立しているため、複数のイベントが短時間に重なった場合に同時表示が起こります。たとえば、アプリ起動から3秒後に課金 CTA を表示するタイマーと、100回起動時にレビューを促す条件判定が、同じセッションで同時に成立してしまうケースです。
入れ子の条件分岐で制御しようとすると、こんなコードになりがちです。
// ❌ 入れ子で書くと優先度が暗黙的になる
useEffect(() => {
if (shouldShowPaywall) {
setShowPaywall(true);
if (shouldShowReview) {
// ここに到達するのかどうかが実行時まで分からない
setShowReviewPrompt(true);
}
} else if (shouldShowReview) {
setShowReviewPrompt(true);
}
}, [shouldShowPaywall, shouldShowReview]);このコードの問題は、shouldShowPaywall と shouldShowReview が非同期で変化したとき、どちらの条件が優先されるかが実行タイミングによって変わることです。テスト環境では再現しないのに本番だけで発生するバグの典型的な原因になります。デバッグのために console.log を仕込んでも、ローカルでは再現しないため手がかりがつかめないことも多いです。
ModalGate パターン — 中央で一元管理する
解決策は、モーダルの表示権を1つの管理オブジェクト(ModalGate)に集約することです。アイデア自体はシンプルです。「同時に1つのモーダルしか表示しない」というルールを、アプリ全体で共有するシングルトンで管理します。
// utils/modalGate.ts
type ModalType = 'paywall' | 'review' | 'rewardedAd' | null;
let currentModal: ModalType = null;
const listeners: Set<(modal: ModalType) => void> = new Set();
export const ModalGate = {
request(modal: Exclude<ModalType, null>): boolean {
// 現在表示中のモーダルがある場合は却下
if (currentModal !== null) {
console.log(`[ModalGate] ${modal} は ${currentModal} 表示中のため却下`);
return false;
}
currentModal = modal;
listeners.forEach(fn => fn(currentModal));
return true;
},
release(modal: Exclude<ModalType, null>): void {
if (currentModal === modal) {
currentModal = null;
listeners.forEach(fn => fn(null));
}
},
subscribe(fn: (modal: ModalType) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
},
};コンポーネント側での使い方はこうなります。
// PaywallDialog.tsx
import { ModalGate } from '../utils/modalGate';
const PaywallDialog: React.FC = () => {
const [visible, setVisible] = useState(false);
const show = useCallback(() => {
// ModalGate に表示権をリクエスト
const granted = ModalGate.request('paywall');
if (granted) {
setVisible(true);
}
}, []);
const hide = useCallback(() => {
setVisible(false);
// 閉じたら必ず解放する
ModalGate.release('paywall');
}, []);
return (
<Modal
visible={visible}
onRequestClose={hide}
onDismiss={hide}
>
{/* 課金画面の内容 */}
</Modal>
);
};ReviewInductionDialog にも同じパターンを適用します。どちらも ModalGate.request() を呼び出すので、先に表示が始まった方が権を持ち、後から来たリクエストは自動的に却下されます。競合状態がコードの外側で解決されるため、各ダイアログコンポーネントは「表示できたか否か」だけを意識すれば済みます。
Rork の生成コードに組み込む方法
Rork が生成したプロジェクトに ModalGate を追加する場合、以下の手順が最もスムーズです。
まず utils/modalGate.ts を新規作成します(上記のコードをそのまま貼り付けます)。Rork のチャット欄に「複数のダイアログが同時に表示されないよう、中央管理するユーティリティを追加して。同時に1つのモーダルしか開かない仕組みにしたい」と指示すると、Rork が上記のようなコードのベースを生成してくれます。
その後、各ダイアログコンポーネントの表示トリガー部分を ModalGate 経由に書き換えます。既存コードを大きく変えなくて済むのが、このパターンの実用上のメリットです。
Rork で生成したコードには useState ベースのモーダル制御が散在していることが多いですが、一元化の対象は「自動表示するモーダル」に絞れば十分です。ユーザーが意図的にタップして開くボトムシートや設定画面は、ModalGate の管理対象にしなくて構いません。ユーザーアクション起因のものは競合が起きにくく、管理対象に加えると逆に複雑になります。
実際に防いだ3つの衝突パターン
Beautiful HD Wallpapers の更新作業で ModalGate が実際に衝突を防いだケースを挙げます。
起動時の課金CTA × 100回記念レビュー誘導
アプリ起動から3秒後に課金CTAを表示するタイマーと、起動100回目でレビューを促す処理が、ちょうど100回目の起動タイミングで競合しました。ModalGate なしでは両方が同時表示されていました。課金CTAが優先的に表示されるようになったことで、コンバージョン率の計測もしやすくなりました。
報酬型広告完了後のPaywall × 非同期 State 更新
報酬型広告の視聴完了イベントで広告なし状態を更新しながら、数秒後に課金案内を表示するロジックが競合しました。非同期処理のタイミングは毎回異なるため、テストで再現しにくい種類のバグでした。このケースは実際に数十件のクラッシュレポートから発見したものです。
DeepLink からの遷移 × 通常起動フロー
特定のプッシュ通知をタップして起動した場合、通常起動フローで予定されていたモーダルと DeepLink 処理が同時に走りました。この衝突はデバイスの状態(バックグラウンド/フォアグラウンド)によって発生頻度が変わるため、QA で見つけにくいものでした。
「閉じ忘れ」への備え
ModalGate パターンで一番気をつけるべき落とし穴は、モーダルを閉じるときに release() を呼び忘れることです。呼び忘れると、以降すべてのモーダルが表示できない状態になります。これはユーザーには「アプリが壊れた」と映り、アンインストールにつながります。
これを防ぐため、Modal コンポーネントの onRequestClose(Android の戻るボタン)と onDismiss の両方に確実に release() を入れておきます。
<Modal
visible={visible}
onRequestClose={hide} // Android 戻るボタン
onDismiss={hide} // iOS スワイプで閉じた場合
>さらに、React Native の useEffect クリーンアップで release() を呼ぶことで、コンポーネントがアンマウントされた場合にも確実に解放できます。
useEffect(() => {
return () => {
// コンポーネントアンマウント時に確実に解放
if (visible) {
ModalGate.release('paywall');
}
};
}, [visible]);宮大工だった両祖父が「どんな細工も仕口(接合部)が一番大事だ」と言っていたのを思い出します。UI の動作も同じで、表示ロジックよりも「閉じる処理」の方が穴になりやすいと実感しています。どれだけ表示の制御が完璧でも、閉じる処理が甘いと全体が壊れます。
優先度キューへの拡張
アプリが大きくなると「課金CTAはレビュー誘導より必ず先に表示したい」という優先度の要件が出てきます。その場合は request() に priority 引数を加え、キューを使った待機型のゲートに拡張できます。ただし、最初から全てのモーダルを優先度付きキューで管理しようとすると複雑になりすぎます。私の経験では、まず先勝ち(=起動順)の ModalGate を導入して、問題が出た箇所だけ優先度を追加するアプローチが現実的です。
ModalGate のデバッグ方法
本番環境で「なぜかモーダルが表示されなくなった」という報告が来たとき、まず確認するのは の状態です。デバッグビルドでは以下のように を使って状態変化をログに出すと原因を特定しやすくなります。
// App.tsx のルートで一度だけ登録する
useEffect(() => {
const unsubscribe = ModalGate.subscribe((modal) => {
console.log('[ModalGate] current:', modal);
});
return unsubscribe;
}, []);release() 呼び忘れが原因の場合、このログを見ると current: paywall のまま変化しないことがわかります。Firebase Crashlytics で把握できないバグカテゴリですが、このログがあれば数分で原因を特定できます。個人開発では、こういう「地味だが確実に効く」診断ツールを丁寧に整備することが、長期的な運用コストを下げると感じています。
複数モーダルの同時表示は、Rork で機能が増えてきたアプリに必ず発生すると言ってもよい問題です。表示トリガーが3つ以上になったタイミングで utils/modalGate.ts を1ファイル追加することを検討してみてください。私自身はこのパターンを導入してから、このカテゴリのバグレポートが完全になくなりました。同じ問題でつまずいている方の参考になれば幸いです。