My App Froze Without Crashing: Two Modals at Once
When an iOS application launches, it is common for multiple independent systems to request user attention. A notification permissions prompt may request presentation on mount, while a marketing banner or onboarding sheet requests presentation a fraction of a second later.
On iOS, the first sheet appears, the second never does, and from that moment forward the entire application stops responding to touch. There is no red error box, no JavaScript exception, and no crash report.
On iOS one modal presents at a time. Queue them; never coordinate with a delay.
This is Day 6 of Ship Native, a standalone production story from shipped applications. Previous lessons covered the three-part Lists series concluding with safe list navigation.
The video uses sample diagrams, English synthetic narration and timed English/Arabic subtitles. I reviewed the TypeScript integration example below, but it was not executed or device-tested.
The native UIKit presentation model
In React Native, <Modal> is not simply a View rendered near the top of the React hierarchy with a high zIndex. On iOS, every <Modal> creates a real native view controller (RCTModalHostViewController or RCTFabricModalHostViewController under the New Architecture) and presents it on the root UIViewController.
UIKit enforces a strict rule: a UIViewController can only present one modal view controller at a time. While a presentation animation is actively running, attempting to present another view controller fails with a native console message:
Attempt to present <RCTFabricModalHostViewController: 0x105820400> on
<UIViewController: 0x105809200> which is already presenting <RCTFabricModalHostViewController: 0x105813600>
This refusal happens entirely in native code. UIKit ignores the second presentation request, but the React Native host component remains mounted in the React tree. Its transparent container sits at the window level, intercepting and swallowing every touch event. The JavaScript engine receives no rejection callback, so the application remains drawn on screen while appearing completely frozen.
Why setTimeout coordination fails
A frequent workaround is delaying the second modal:
// Flawed workaround: arbitrary timer coordination
useEffect(() => {
const timer = setTimeout(() => {
setShowPoster(true);
}, 400);
return () => clearTimeout(timer);
}, []);
Coordinating native presentations with a timer fails under real-world conditions:
- Hardware variation makes fixed timers unreliable. A 400 ms delay might succeed on an iPhone 15 Pro, but fail on an older iPad under heavy CPU load.
- System animations slow down during low power mode, thermal throttling, or device orientation changes.
- If the user dismisses the first modal quickly, or if the timer fires mid-transition, the presentation race condition returns.
A timer is a scheduling guess, not a state guarantee.
The solution: A turn-based presentation queue
Instead of showing themselves directly, modals register their request in a shared queue. The queue allows only one presenter to hold the active turn.
When the current modal dismisses, the queue enforces a short handover delay (400 ms) to let the native dismissal transition settle before granting the turn to the next waiting modal.
import React, { useEffect, useSyncExternalStore, useState } from 'react';
import {
Modal,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
/**
* Ship Native - Day 6 Teaching Code
* Production Story: Modal Presentation Queue for React Native
*
* Background:
* On iOS, React Native's <Modal> maps to a native UIViewController presentation.
* Attempting to present a second UIViewController while the first is presenting
* or dismissing results in a UIKit refusal:
* "Attempt to present <RCTFabricModalHostViewController: ...> which is already presenting"
*
* The second modal never displays, but its host view remains mounted in the hierarchy,
* swallowing all touch events and freezing the screen without throwing any JavaScript error.
*/
type Listener = () => void;
// Handover pause in milliseconds to allow the native dismissal animation to settle.
const HANDOVER_MS = 400;
const waiting: string[] = [];
const listeners = new Set<Listener>();
let handingOver = false;
let handoverTimer: ReturnType<typeof setTimeout> | null = null;
const notify = () => {
listeners.forEach(listener => listener());
};
/**
* Returns the identifier of the modal permitted to present right now.
* Returns null during dismissal handover to prevent overlapping presentations.
*/
export const currentModalTurn = (): string | null =>
handingOver ? null : waiting[0] ?? null;
export const subscribeToModalQueue = (listener: Listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const requestModalTurn = (id: string) => {
if (waiting.includes(id)) {
return;
}
waiting.push(id);
notify();
};
export const releaseModalTurn = (id: string) => {
const index = waiting.indexOf(id);
if (index === -1) {
return;
}
const wasHolding = index === 0;
waiting.splice(index, 1);
// If the dismissed modal was still waiting in queue, no transition is running.
if (!wasHolding) {
notify();
return;
}
// Active modal dismissed: wait for native transition to complete.
handingOver = true;
notify();
if (handoverTimer) {
clearTimeout(handoverTimer);
}
handoverTimer = setTimeout(() => {
handoverTimer = null;
handingOver = false;
notify();
}, HANDOVER_MS);
};
/**
* Custom hook that arbitrates modal presentation.
* Pass the modal ID and whether your component wants to present.
* Returns true only when it is this modal's turn in the queue.
*/
export const useModalTurn = (id: string, wanted: boolean): boolean => {
useEffect(() => {
if (wanted) {
requestModalTurn(id);
} else {
releaseModalTurn(id);
}
}, [id, wanted]);
// Clean up if the component unmounts while holding or waiting in the queue.
useEffect(() => () => releaseModalTurn(id), [id]);
const holder = useSyncExternalStore(subscribeToModalQueue, currentModalTurn);
return wanted && holder === id;
};
// --- Example Component Demonstrating Coordinated Presentation ---
interface QueuedSheetProps {
id: string;
wanted: boolean;
title: string;
message: string;
actionLabel: string;
onDismiss: () => void;
}
export const QueuedSheet: React.FC<QueuedSheetProps> = ({
id,
wanted,
title,
message,
actionLabel,
onDismiss,
}) => {
const isTurn = useModalTurn(id, wanted);
return (
<Modal
visible={isTurn}
transparent
animationType="fade"
onRequestClose={onDismiss}
>
<View style={styles.overlay}>
<View style={styles.card}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.message}>{message}</Text>
<TouchableOpacity style={styles.button} onPress={onDismiss}>
<Text style={styles.buttonText}>{actionLabel}</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
export const ModalQueueDemo: React.FC = () => {
// In the real reproduction, two alerts request presentation almost simultaneously on launch.
const [showPermission, setShowPermission] = useState(true);
const [showPromo, setShowPromo] = useState(true);
return (
<View style={styles.container}>
<Text style={styles.header}>Ship Native - Modal Queue Demo</Text>
<Text style={styles.status}>
Permission Sheet: {showPermission ? 'Wanted' : 'Closed'}
</Text>
<Text style={styles.status}>
Promo Poster: {showPromo ? 'Wanted' : 'Closed'}
</Text>
<QueuedSheet
id="permission-sheet"
wanted={showPermission}
title="Stay in the Loop"
message="Enable notifications to receive delivery and order updates."
actionLabel="Continue"
onDismiss={() => setShowPermission(false)}
/>
<QueuedSheet
id="promo-poster"
wanted={showPromo}
title="Special Offer"
message="Enjoy 20% off your next checkout with promo code NATIVE."
actionLabel="Claim Offer"
onDismiss={() => setShowPromo(false)}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#11151b',
padding: 24,
},
header: {
fontSize: 22,
fontWeight: 'bold',
color: '#f3f1ea',
marginBottom: 16,
},
status: {
fontSize: 16,
color: '#8b9bb4',
marginBottom: 8,
},
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.65)',
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
card: {
width: '100%',
maxWidth: 400,
backgroundColor: '#1b212a',
borderRadius: 16,
padding: 24,
borderWidth: 1,
borderColor: '#2b3644',
},
title: {
fontSize: 20,
fontWeight: '700',
color: '#f3f1ea',
marginBottom: 10,
},
message: {
fontSize: 15,
color: '#c5cfdb',
lineHeight: 22,
marginBottom: 20,
},
button: {
backgroundColor: '#a8edc4',
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
},
buttonText: {
color: '#0e1e17',
fontSize: 16,
fontWeight: '600',
},
});
How useSyncExternalStore guarantees consistency
The queue state lives outside the React tree, avoiding cascading component re-renders. We subscribe to it using useSyncExternalStore:
- Subscriptions are synchronous, so every participating modal component updates together without state tearing.
- If a screen or modal unmounts while holding or waiting in the queue, the cleanup effect calls releaseModalTurn(id) to prevent deadlocks.
Platform differences: iOS vs Android
On Android, React Native <Modal> instances render inside their own separate native dialog windows (android.app.Dialog). Android allows dialogs to stack natively, so simultaneous presentations do not cause the same touch-freeze deadlock.
However, displaying two full-screen modals at once on Android produces a confusing user experience where one dialog abruptly obscures the other. Using the presentation queue ensures clean, predictable, sequential dialogs on both iOS and Android.
Verification steps in your app
To verify your application's modal handling:
- Render two modals with visible set to true on initial mount. On iOS without a queue, observe the touch freeze and inspect Xcode console for the presentation refusal.
- Apply useModalTurn. Verify that the first modal presents, and upon dismissal, the second modal appears smoothly after the handover window.
- Trigger a modal request and immediately navigate away before it presents. Ensure subsequent modals on new screens can still acquire turns.
Summary
- React Native
<Modal>on iOS is a UIKit presentation. - Presenting into an active presentation drops touches without a JavaScript error.
- Never use
setTimeoutto space out modal presentations. - Queue presentation requests and allow dismissal transitions to complete before the next modal appears.
What comes next
Next: The Android Update That Broke My Form Layout, Day 7, Forms and uploads 1 of 3. Its video link will be added when published.
Comments