The other day I was using one of my own Rork-built apps on my iPhone for the first time in a while. Right after I saved a single image, a review prompt appeared. The moment I dismissed it, a paywall sheet slid up. And behind that, an ad had apparently finished loading, because when I closed the paywall, a full-screen ad took over. Three modals in three seconds. Even though I had built the app myself, I was honestly annoyed.
What struck me was that the version of me who wrote each of those dialogs had placed every one of them with good intentions. You saved something, so you must be satisfied — show the review prompt. You used a feature, so suggest the upgrade. You reached a natural break, so show the ad. Each decision was reasonable on its own. But when each one fires independently, unaware that the others exist, the user simply experiences "an app that throws annoying things at me one after another." Today I want to write down how I directed that traffic, from the perspective of running apps with over 50 million cumulative downloads across 12 years.
Why modals pile up despite good intentions
The root of the problem is that each modal is triggered by a different event.
A review prompt feels right after a satisfaction signal like a save or a favorite. A paywall feels right at the third use, or when the free quota is hit. An ad feels right at a screen transition or a session break. In a real app, these events almost always land in the same instant — because the moment a user accomplishes something is simultaneously a satisfaction signal, a usage milestone, and a screen break.
So as long as the triggers live in separate places, the overlap isn't an occasional accident. It's structurally guaranteed. I only understood this after getting burned repeatedly while running Beautiful HD Wallpapers. There was no bug anywhere in the code, yet the experience was broken. The cause was three correct decisions colliding in the same spot.
Centralize the decision of what to show
The solution I arrived at is simple: take the decision of "which modal to show" away from each feature, and hand it entirely to a single gatekeeper. In my native apps I implement this as a central manager I call ModalGate. Each feature only requests permission to appear; whether it actually appears is decided in one place.
As code, the idea looks like this. Define the priority in one spot, and only when nothing is currently on screen, show exactly one — the highest-priority pending modal.
// modalGate.ts — direct all modal traffic from one place
type ModalKind = "rewardAd" | "paywall" | "reviewPrompt";
// Smaller number = higher priority. The order — ad (revenue),
// paywall (business), review (asset) — keeps the last caller from winning
const PRIORITY: Record<ModalKind, number> = {
rewardAd: 1,
paywall: 2,
reviewPrompt: 3,
};
let activeModal: ModalKind | null = null;
const pending = new Set<ModalKind>();
// Each feature just requests here
export function requestModal(kind: ModalKind) {
pending.add(kind);
flush();
}
function flush() {
// If something is already up, never stack on top of it
if (activeModal !== null) return;
if (pending.size === 0) return;
// Pick only the single highest-priority pending modal
const next = [...pending].sort((a, b) => PRIORITY[a] - PRIORITY[b])[0];
pending.delete(next);
activeModal = next;
showModal(next); // your actual presentation logic
}
// When a modal closes, consider the next one
export function onModalDismissed() {
activeModal = null;
// Showing the next immediately becomes a chain, so leave a gap
setTimeout(flush, 800);
}The most important line here is the setTimeout inside onModalDismissed. If you show the next modal the instant one closes, you're back to "an app that throws things at me in a row." I leave at least 0.8 seconds of empty space after a dismissal, and ideally, if the user has moved to a different screen during that gap, I don't show the next one at all. Not stacking and not chaining are two different problems, and the gate has to handle both.
Bringing this into a Rork app
Apps generated by Rork are React Native under the hood, so this traffic control carries over directly. What I found practical was making the request gate a React context, so each screen only says "I'd like to appear."
// useModalGate.tsx — a minimal gate for a Rork (React Native) app
import { createContext, useContext, useRef, useState, useCallback } from "react";
type ModalKind = "rewardAd" | "paywall" | "reviewPrompt";
const PRIORITY: Record<ModalKind, number> = { rewardAd: 1, paywall: 2, reviewPrompt: 3 };
const Ctx = createContext<(k: ModalKind) => void>(() => {});
export function ModalGateProvider({ children }: { children: React.ReactNode }) {
const [active, setActive] = useState<ModalKind | null>(null);
const pending = useRef<Set<ModalKind>>(new Set());
const flush = useCallback(() => {
if (active !== null || pending.current.size === 0) return;
const next = [...pending.current].sort((a, b) => PRIORITY[a] - PRIORITY[b])[0];
pending.current.delete(next);
setActive(next);
}, [active]);
const request = useCallback((kind: ModalKind) => {
pending.current.add(kind);
flush();
}, [flush]);
const dismiss = useCallback(() => {
setActive(null);
setTimeout(flush, 800); // the gap that prevents chaining
}, [flush]);
return (
<Ctx.Provider value={request}>
{children}
{/* render exactly one modal based on the active value */}
{active === "paywall" && <Paywall onClose={dismiss} />}
{active === "reviewPrompt" && <ReviewPrompt onClose={dismiss} />}
{active === "rewardAd" && <RewardAd onClose={dismiss} />}
</Ctx.Provider>
);
}
// Screens just call this. The gate decides whether to show anything
export const useRequestModal = () => useContext(Ctx);When you tell Rork "show a review prompt when the save button is tapped," it often writes the dialog presentation directly into the button's onPress. That works fine on its own — but as you add paywall and ad logic through separate prompts, each ends up with its own independent firing point. So my workflow is to build everything with Rork first, then rewrite only the presentation logic to go through this gate. Generation speed and a design that survives real operation work better when you separate the two steps.
When to show — the priority order itself
Once the gate exists, the last question is: when all three arrive at once, which one wins? My criterion is how easily each can be recovered.
An ad, if not shown in that moment, loses the revenue opportunity — you can't replay "the ad from earlier." A paywall, even if you skip it now, can be shown again at the next milestone. And a review prompt is the most precious asset of all, because the OS caps how many times it can appear per year. So my priority is ad, then paywall, then review, and I save the review prompt for the moment when nothing else is showing and the user is clearly satisfied.
This order changes with the shape of your business. For an app where ad revenue is thin and subscriptions are the core, the paywall should come first. What matters isn't the specific order but the state of having decided it deliberately in one place. In an app like Law of Attraction Everyday that people open daily, I delay the review prompt to a moment I can recommend wholeheartedly — like the day a user opens it seven days in a row. Getting three stars by asking early helps less than getting five stars by asking late.
I'm reminded of my grandfather, a temple carpenter, who always aligned the grain of the wood before driving a single nail. Each nail can go in correctly, but if the grain isn't aligned, the whole thing warps. App modals are the same: each individual decision being correct, and the whole feeling pleasant to use, are two things you have to design separately.
Your next step
If you have an app in production right now, start by writing down where the review prompt, paywall, and ad presentations are called from in your code. If all three are called from different files, that's your source of overlap. As a first step, build one small gate like the requestModal in this article and route just two of the modals through it — even that alone changes the experience noticeably. Thank you for reading.