While updating Beautiful HD Wallpapers (Android v2.1.0), I ran into a bug where the PaywallDialog and ReviewInductionDialog were both appearing at the same time. Users couldn't interact with either — one was blocking the other.
After 12 years and over 50 million downloads across my personal app portfolio, I've come to recognize this as one of the trickiest categories of bugs to catch. React Native apps (which Rork generates) make it surprisingly easy to trigger multiple modals simultaneously, especially as features grow.
Why Modals Overlap in the First Place
In a typical Rork-generated React Native app, each modal has its own local state flag:
const [showPaywall, setShowPaywall] = useState(false);
const [showReviewPrompt, setShowReviewPrompt] = useState(false);
const [showRewardedAd, setShowRewardedAd] = useState(false);When these are controlled independently, two events firing close together — say, a 3-second launch timer for your paywall and a 100th-launch check for a review prompt — can both resolve to true in the same render cycle.
A common but flawed fix is nesting the conditions:
// ❌ Nesting makes priority implicit and fragile
useEffect(() => {
if (shouldShowPaywall) {
setShowPaywall(true);
if (shouldShowReview) {
// Whether this line is reached depends on async timing
setShowReviewPrompt(true);
}
} else if (shouldShowReview) {
setShowReviewPrompt(true);
}
}, [shouldShowPaywall, shouldShowReview]);The problem: when shouldShowPaywall and shouldShowReview change asynchronously, which branch runs first depends on execution timing. This produces bugs that only appear in production and are nearly impossible to reproduce in tests.
The ModalGate Pattern — Central Ownership of Display Rights
The fix is to give one object — a ModalGate — exclusive authority over which modal is visible. The rule is simple: only one modal can hold the display right at a time.
// 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} rejected — ${currentModal} is active`);
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);
},
};Using it in a component looks like this:
// PaywallDialog.tsx
import { ModalGate } from '../utils/modalGate';
const PaywallDialog: React.FC = () => {
const [visible, setVisible] = useState(false);
const show = useCallback(() => {
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}
>
{/* paywall content */}
</Modal>
);
};Apply the same pattern to ReviewInductionDialog. Whichever dialog calls request() first wins the display right; any later request is automatically rejected.
Adding ModalGate to a Rork Project
In a Rork-generated project, the fastest way to add this is:
First, create utils/modalGate.ts with the code above. You can also prompt Rork directly: "Add a utility to prevent multiple dialogs from showing at the same time — only one modal should be open at once." Rork will generate a base implementation you can refine.
Then update each dialog component to route its show/hide calls through ModalGate. The key advantage is that this doesn't require restructuring your existing components — just intercept the show trigger and add release() to the dismiss handlers.
Three Real Collisions This Prevented
Here are the actual overlap scenarios ModalGate resolved in the Beautiful HD Wallpapers update:
Launch timer paywall vs. 100th-launch review prompt
A 3-second post-launch paywall timer and a launch-count check both resolved on the app's 100th launch. Without ModalGate, both dialogs appeared. With it, the paywall got the display right and the review prompt was silently deferred.
Rewarded ad completion callback vs. async state update
After a user watched a rewarded ad, the app updated the ad-free state and then queued a paywall prompt a few seconds later. Because the async completion callback doesn't fire at a consistent time, this collision was impossible to reproduce reliably in testing — but happened to real users.
Deep link navigation vs. normal launch flow
When users tapped certain push notifications, the deep link handling and the normal launch flow's modal logic both ran. The collision rate depended on whether the app was backgrounded or cold-started, making it another test-invisible but production-visible bug.
Guard Against Forgetting to Release
The main footgun with ModalGate is forgetting to call release(). If you skip it, no modal will ever show again for the rest of that session.
The safest approach: always add release() to both onRequestClose (Android back button) and onDismiss (iOS swipe-dismiss):
<Modal
visible={visible}
onRequestClose={hide} // Android back button
onDismiss={hide} // iOS swipe-to-dismiss
>My grandfather was a miyadaiku — a craftsman who built Shinto shrines. He used to say the joint is always where a structure fails, never the flat surface. I find that holds true for UI logic too: the dismiss path is more likely to be buggy than the show path.
If your Rork app has three or more modal triggers, it's worth adding utils/modalGate.ts now rather than waiting for overlap to happen in production. The file is small, the integration is minimal, and it eliminates an entire category of hard-to-reproduce bugs. Since introducing this pattern, I haven't received a single crash report from modal overlap in my apps.