「閉じるボタンを押しても Modal が閉じない」「背景の暗い部分をタップしても反応しない」「Modal の中のボタンが効かない」── Rork で AI にダイアログを生成させたとき、最初にぶつかる壁の上位がこの Modal 周りの挙動です。生成されたコード自体は一見正しく動きそうに見えるのに、実機で開くと閉じられなくなります。プロンプトを書き直しても、同じパターンで詰まることが多いのではないでしょうか。
実はこの問題、原因の8割は決まった5つのパターンに集約されます。React Native の Modal コンポーネントは便利な反面、Web の感覚で扱うと落とし穴が多いコンポーネントでもあります。ここでは私が Rork で複数のアプリを作る中で繰り返し踏んできた地雷と、その直し方を順番に整理していきます。
なぜ Rork 生成の Modal は閉じなくなりやすいのか
Rork の AI は React Native の Modal を transparent={false} のまま生成することがあります。Web の <dialog> のような感覚でプロンプトを書くと、AI も Web 寄りの解釈で組み立ててしまうのです。その結果、画面全体を不透明な View が覆い、背景に置いたはずの「閉じるエリア」が物理的にタップ不能になります。
もう一つの根本原因が、onRequestClose の実装漏れです。これは Android の戻るボタンと連動するハンドラで、未実装でも iOS では一見動いているように見えるため見落としがちです。Apple Silicon Mac の iOS シミュレータだけで開発を進めていると、Android 実機に渡した瞬間に「閉じない」と報告されます。
公式ドキュメントには小さく書いてあるのですが、Modal は他のコンポーネントとは別レイヤー(ネイティブの window)にレンダリングされる特殊な存在です。この性質を理解していないと、pointerEvents や KeyboardAvoidingView との組み合わせで予期しない挙動が頻発します。
落とし穴1: transparent と onRequestClose の指定漏れ
最も多いのがこれです。Rork が生成しがちな最小コードを見てみましょう。
// ❌ よくある不完全な実装(背景タップで閉じない・Android 戻るで閉じない)
import { Modal, View, Text, Pressable } from 'react-native';
export function BadModal({ visible, onClose }: { visible: boolean; onClose: () => void }) {
return (
<Modal visible={visible} animationType="fade">
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>閉じるボタンが効かないことがあります</Text>
<Pressable onPress={onClose}>
<Text>閉じる</Text>
</Pressable>
</View>
</Modal>
);
}このコードは iOS シミュレータでは動くように見えます。しかし transparent を指定していないため Modal の背景は不透明な白で塗られ、背景タップでの閉じる動作が実装できません。さらに onRequestClose がないため、Android の戻るボタンを押しても何も起きず、ユーザーはアプリを強制終了するしかなくなります。
直し方は次の通りです。
// ✅ 推奨パターン
import { Modal, View, Text, Pressable, StyleSheet } from 'react-native';
export function GoodModal({ visible, onClose }: { visible: boolean; onClose: () => void }) {
return (
<Modal
visible={visible}
transparent={true}
animationType="fade"
onRequestClose={onClose} // Android 戻るボタン対応(必須)
statusBarTranslucent={true} // Android のステータスバー領域も覆う
>
{/* 背景タップで閉じる領域 */}
<Pressable style={styles.backdrop} onPress={onClose}>
{/* コンテンツ部分は伝播を止める */}
<Pressable style={styles.content} onPress={(e) => e.stopPropagation()}>
<Text>確実に閉じられる Modal です</Text>
<Pressable onPress={onClose} style={styles.button}>
<Text>閉じる</Text>
</Pressable>
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
content: {
backgroundColor: '#fff',
padding: 24,
borderRadius: 12,
minWidth: 280,
},
button: { marginTop: 16, padding: 12 },
});ポイントは2つです。背景の Pressable で onClose を呼び、コンテンツ側の Pressable で e.stopPropagation() を呼んでイベントの伝播を止めること。これで「背景タップで閉じる、コンテンツ内のタップでは閉じない」という挙動が両プラットフォームで揃います。
落とし穴2: Modal の中で TextInput がフォーカスを失う
ログインダイアログやコメント入力ダイアログで頻発します。Modal を開くと TextInput にフォーカスが当たるが、キーボードが表示された瞬間に Modal がリレイアウトされてフォーカスが外れる、という症状です。
原因は親コンポーネントの再レンダリングです。Modal の visible プロパティを親の state で管理している場合、親の state が変わるたびに Modal の中身もリレンダリングされ、TextInput が新しいインスタンスに置き換わります。Rork が生成するコードはこの落とし穴に陥りやすい構造です。
// ❌ 親が再レンダーすると TextInput が再生成されてフォーカスが飛ぶ
function Parent() {
const [visible, setVisible] = useState(false);
const [text, setText] = useState('');
// text が変わるたびに Parent が再レンダー → Modal も再レンダー
return (
<Modal visible={visible} transparent onRequestClose={() => setVisible(false)}>
<TextInput value={text} onChangeText={setText} autoFocus />
</Modal>
);
}修正方針は、Modal の中身を別コンポーネントに切り出すことです。
// ✅ 中身を子コンポーネントに分離してフォーカス保持
function ModalContent({ onClose }: { onClose: () => void }) {
const [text, setText] = useState('');
return (
<View style={{ padding: 24, backgroundColor: '#fff' }}>
<TextInput
value={text}
onChangeText={setText}
autoFocus
style={{ borderWidth: 1, padding: 8 }}
/>
<Pressable onPress={onClose}><Text>送信</Text></Pressable>
</View>
);
}
function Parent() {
const [visible, setVisible] = useState(false);
return (
<Modal visible={visible} transparent onRequestClose={() => setVisible(false)}>
<ModalContent onClose={() => setVisible(false)} />
</Modal>
);
}これだけでフォーカス問題は解消します。Rork に修正を依頼するときは「Modal の中身を別コンポーネントに分離してください」と明示的に指示すると、一発で適切な構造を出力してくれることが多いです。
落とし穴3: KeyboardAvoidingView との組み合わせでタップが効かなくなる
入力 Modal でキーボードが Modal を覆ってしまう問題を解決しようと KeyboardAvoidingView を入れた瞬間、今度は背景タップが効かなくなるという二次災害が起きやすいです。
KeyboardAvoidingView は内部で View を絶対配置するため、Pressable の親子関係が崩れます。背景タップ用の Pressable がイベントを受け取る前に KeyboardAvoidingView が捕まえてしまうのです。
解決策は、KeyboardAvoidingView を背景の Pressable の内側に置くことです。
// ✅ 階層を入れ替えて両立させる
<Modal visible={visible} transparent onRequestClose={onClose}>
<Pressable style={styles.backdrop} onPress={onClose}>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={{ width: '100%', alignItems: 'center' }}
>
<Pressable style={styles.content} onPress={(e) => e.stopPropagation()}>
<TextInput style={styles.input} placeholder="メッセージを入力" />
</Pressable>
</KeyboardAvoidingView>
</Pressable>
</Modal>KeyboardAvoidingView の behavior は iOS では padding、Android では height が安定します。Android では多くの場合 windowSoftInputMode が adjustResize であれば KeyboardAvoidingView を入れなくてもキーボード回避が効くため、Android 専用ロジックの場合は省略する選択肢もあります。
落とし穴4: Modal を FlatList の中に入れてしまう
リストアイテムごとに「詳細を見る」ボタンを置き、Modal でアイテムの詳細を表示する設計でよく起きるパターンです。
// ❌ FlatList の renderItem の中で Modal をレンダーすると、リスト要素ごとに Modal が量産される
<FlatList
data={items}
renderItem={({ item }) => (
<View>
<Pressable onPress={() => setOpenId(item.id)}><Text>{item.title}</Text></Pressable>
<Modal visible={openId === item.id} transparent onRequestClose={() => setOpenId(null)}>
{/* 100アイテムなら100個の Modal がツリーに存在することになる */}
</Modal>
</View>
)}
/>これは動作はしますが、Modal の内部実装はネイティブの Window を作るため、リスト件数ぶんネイティブビューが生成されてメモリを圧迫します。Android では1000件超えで明らかに重くなります。
正しい設計は、Modal を FlatList の外側に1つだけ置き、表示する内容を state で切り替えることです。
// ✅ Modal は1つだけ。中身を state で切り替える
function ListWithDetailModal() {
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
return (
<>
<FlatList
data={items}
renderItem={({ item }) => (
<Pressable onPress={() => setSelectedItem(item)}>
<Text>{item.title}</Text>
</Pressable>
)}
/>
<Modal
visible={selectedItem !== null}
transparent
onRequestClose={() => setSelectedItem(null)}
>
{selectedItem && <DetailContent item={selectedItem} onClose={() => setSelectedItem(null)} />}
</Modal>
</>
);
}この構造に変えるだけで、リストの長さにかかわらず Modal は常に1つです。Rork に「Modal は FlatList の外に出して、選択中のアイテムを state で持ってください」と伝えれば、この形で書き直してくれます。
落とし穴5: 入れ子 Modal とプラットフォーム差
「設定 Modal の中から、確認用のアラート Modal を開きたい」というケースで陥りがちです。
iOS では Modal の入れ子は基本的に動作しますが、Android では2つ目の Modal が透けて見えなかったり、戻るボタンの挙動がおかしくなったりします。React Native の Modal はネイティブのモーダル機構(iOS の presentViewController:、Android の Dialog)に依存しているため、プラットフォームの仕様差がそのまま顔を出します。
安全策は3つあります。第一に、確認ダイアログは Alert.alert() で済ませます。これはネイティブのアラートを使うため、入れ子の問題が起きません。第二に、本当に Modal を入れ子にしたい場合は、外側の Modal を一度閉じてから内側を開く設計に変えます。第三に、react-native-modal などのサードパーティライブラリを使い、純粋な React 階層で Modal を実装する方法もあります。
// ✅ Alert で確認 → ユーザーが OK したら親 Modal を閉じる
import { Alert } from 'react-native';
function SettingsModal({ visible, onClose }: Props) {
const handleReset = () => {
Alert.alert(
'設定をリセットしますか?',
'この操作は取り消せません',
[
{ text: 'キャンセル', style: 'cancel' },
{ text: 'リセット', style: 'destructive', onPress: () => { resetSettings(); onClose(); } },
]
);
};
return (
<Modal visible={visible} transparent onRequestClose={onClose}>
{/* ... */}
<Pressable onPress={handleReset}><Text>リセット</Text></Pressable>
</Modal>
);
}Alert.alert は OS 標準の見た目になるため、デザイン的にも違和感がありません。Rork に「確認ダイアログは Alert.alert を使ってください」と指示すれば、適切に組み込んでくれます。
全体を振り返って — 次にやること
Modal が閉じない問題に当たったら、まず開発しているコードを開いて transparent={true} と onRequestClose の有無を確認してください。この2つが揃っていない実装は8割の確率でこの記事のいずれかの落とし穴に該当します。Rork で生成し直す際は「transparent と onRequestClose を必ず付けて、背景タップで閉じる構造にしてください」と一文添えるだけで、完成度がぐっと上がります。
Rork のトラブルシューティングをもう少し体系的に押さえたい方は、FlatList が空・表示されない問題のデバッグ手順 と Pull to Refresh のチラつき・スクロール不具合の直し方 も合わせて読んでみてください。リスト系コンポーネントの落とし穴は Modal と地続きの問題が多く、両方押さえておくと UI 周りの不具合に強くなれます。
Rork が生成するコードを「読める」ようになると、AI への指示精度も明らかに変わってきます。