RORK LABEN
TOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しましたTOOLING — Rorkの開発者向けリポジトリが動き続けています。rork-xcodeが7月16日、rork-deviceが7月15日、rork-plistが7月13日に更新されましたOPUS46 — RorkでClaude Opus 4.6が稼働しています。Rork MaxはClaude Codeを土台にアプリを組み立てる設計ですSIM — ブラウザ上で動くクラウドのiOSシミュレータを備え、実機へのインストールは1クリック、App Storeへの提出は2クリックと案内されていますMAX — Rork MaxはReact Nativeではなく純粋なSwiftを出力します。iPhone・iPad・Apple Watch・Apple TV・Vision Pro、そしてiMessageまでが射程ですNATIVE — HealthKit、ARKitとLiDAR、NFC、Dynamic Island、Live Activities、Metalによる3D、Core MLのオンデバイス推論まで扱えますSEED — RorkはLeft Lane Capitalが主導する1,500万ドルのシードラウンドを実施し、Peak XVとa16z Speedrunが参加しました
記事一覧/開発ツール
開発ツール/2026-05-15中級

Rork アプリで課金画面とレビュー誘導が同時に出た — ModalGate パターンで解決するまで

Rork アプリで複数のモーダルが同時表示されるバグの根本原因と、ModalGate パターンによる解決方法を実装例とともに解説します。

Rork515React Native209Modal2UI設計2バグ修正2

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]);

このコードの問題は、shouldShowPaywallshouldShowReview が非同期で変化したとき、どちらの条件が優先されるかが実行タイミングによって変わることです。テスト環境では再現しないのに本番だけで発生するバグの典型的な原因になります。デバッグのために 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ファイル追加することを検討してみてください。私自身はこのパターンを導入してから、このカテゴリのバグレポートが完全になくなりました。同じ問題でつまずいている方の参考になれば幸いです。

シェア

お読みいただきありがとうございます

Rork Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • コピー&ペーストで使える実装コード付き
  • 毎日新しい上級ガイドを追加
  • ¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

開発ツール2026-04-26
Rork で Modal が閉じない・タップが通らない問題 — 詰まりやすい5つの落とし穴
Rork で生成した React Native Modal が閉じない、背景タップに反応しない、内部のボタンが押せない。原因の8割は同じ場所にあります。実装パターンごとの直し方をまとめました。
開発ツール2026-07-15
「、」が行頭に落ちた — Rork が組んだ名言カードと日本語の禁則処理
React Native の Text は日本語の行頭禁則を保証しません。実測した違反率、WORD JOINER による事前結合の実装、onTextLayout での自動監査、Rork Max(SwiftUI)との挙動差までまとめました。
開発ツール2026-07-15
Rorkの癒しアプリにスリープタイマーを付けたら、フェードアウトが実際の音とズレた話
Rorkが生成するスリープタイマーは、壁時計のsetIntervalで音量を下げます。数値上は30分でも、実際の音はもっと早く消えたり不自然に途切れたりします。フェードを再生位置で駆動し直す実装と、対数カーブ・停止処理・画面ロック時の限界までを実運用の視点でまとめました。
📚RECOMMENDED BOOKS
大規模言語モデル入門
山田育矢
LLM開発
生成AIプロンプトエンジニアリング入門
我妻幸長
プロンプト
Claude CodeによるAI駆動開発入門
平川知秀
AI駆動開発
※ アフィリエイトリンクを含みます
もっと見る →