●RORK MAX — Rork Max can now build native Swift apps for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro●PUBLISH — Rork Max offers two-click App Store publishing with no Xcode required, cutting the friction of getting an app shipped●EXPO — The standard Rork is built on React Native (Expo), generating native iOS and Android apps from plain-English descriptions●PRICING — Rork is free to start, with paid plans beginning at $25/month, an accessible tier for solo developers●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz) as investment keeps flowing into AI app builders●REVIEW — In real use the keys are generated-code readability and maintainability, Expo-related constraints, and how easily billing, push, and ad SDKs slot in●RORK MAX — Rork Max can now build native Swift apps for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro●PUBLISH — Rork Max offers two-click App Store publishing with no Xcode required, cutting the friction of getting an app shipped●EXPO — The standard Rork is built on React Native (Expo), generating native iOS and Android apps from plain-English descriptions●PRICING — Rork is free to start, with paid plans beginning at $25/month, an accessible tier for solo developers●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz) as investment keeps flowing into AI app builders●REVIEW — In real use the keys are generated-code readability and maintainability, Expo-related constraints, and how easily billing, push, and ad SDKs slot in
The Day a Third Reason to Hide Ads Appeared — Folding Rork App Ad-Free Logic Into One Place
Ads show only on one screen for paying users, or ads never show for free users. The usual cause is that the condition for hiding ads is scattered across the code. Here is how I fold three reasons — subscription, lifetime purchase, and a timed reward unlock — into a single state and route every ad through one hook, written as an implementation note from running six apps as an indie developer.
I once got an App Store review from a user saying ads still showed even though they had paid. When I dug in, the purchase state itself was read correctly, and ads were properly hidden on the home screen. The only place ads remained was a newer "favorites" screen I had added later.
The cause was easy to find. When I built that screen, I decided whether to show ads with a single line: if (!isSubscribed). But by then my app had not one but three reasons to hide ads — a subscription, a lifetime ad-removal purchase, and a timed unlock where watching a rewarded ad hides ads for a while. The new screen only looked at the subscription, so ads appeared only for users who had bought the lifetime option.
When there is just one condition, if (!isSubscribed) is fine. The trouble starts when monetization quietly adds more reasons to hide ads, and the checks you wrote earlier get left behind. This article is about cutting that drift off at the root: folding the ad-free decision into one piece of state and routing every screen through the same entry point, assuming an Expo app generated by Rork.
Reasons to Hide Ads Pile Up Without You Noticing
When I first shipped, my app had exactly one way to remove ads: a lifetime purchase. Six months later I added a subscription, and then, as a retention play, a timed unlock through rewarded ads. Each arrived at a different time, from a different monetization decision.
This is where the decision logic scatters. You touch the ad code when you add the subscription, and touch it again when you add the reward. Each time, the scope you need to update is supposedly "everywhere ads appear" — but human memory is unreliable. Once you pass ten screens, something always gets missed.
And the miss cuts both ways. Forget to hide ads, and you show ads to people who paid, which leads directly to refunds and lower ratings. Hide them too aggressively, and free users see no ads, so that revenue disappears too. Both drift silently, so you usually notice only after the numbers move.
So the real problem is not "the check is wrong." It is "the same check lives in several places and only one of them gets updated." What needs fixing is not the individual if, but the structure that should gather the decision into a single location.
Folding Three Reasons Into One State
First, make the reasons to hide ads explicit as a type. Code that Rork emits tends to keep logic closed within each screen, so pulling this out into a shared module is the first step.
// lib/adFree/types.tsexport type AdFreeReason = | 'subscription' // an active subscription | 'lifetime' // a one-time ad-removal purchase | 'reward' // a timed unlock from a rewarded ad (has an expiry) | null; // show ads (no unlock)export interface AdFreeState { reason: AdFreeReason; // only meaningful when reason === 'reward'. Epoch milliseconds. rewardExpiresAt: number | null;}
The key is to not carry an isAdFree: boolean from the start. If all you keep is a boolean, you later lose track of why ads are hidden, and you cannot trace bugs like "the reward expired but ads stayed off." Keeping the reason lets you feed it straight into logs, and lets the UI show things like "ads are off for another 32 minutes."
The three reasons have a precedence. I evaluate them in this order:
lifetime — once unlocked, it is permanent. Highest priority; ads never show regardless of any other state.
subscription — no ads while the entitlement is active.
reward — no ads only while the window has not expired.
This ordering matters when, say, a user who already owns the lifetime purchase happens to watch a rewarded ad. Because lifetime is highest priority, the behavior never gets dragged around by the reward's expiry bookkeeping. Part of the value of gathering the decision in one place is that you write this precedence correctly exactly once.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦The full implementation of a useAdFree selector that composes subscription, lifetime, and a timed reward unlock with explicit precedence
✦A timer design that fires exactly at the next expiry instead of every second, so a reward window flips off the moment it should
✦A measured fix for the bug where rewinding the device clock keeps ads hidden forever, closed with a monotonic-time check
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Aggregate the composed state into one store. Zustand is comfortable for sharing state in an Expo app, so I use it here. Subscription and lifetime come from RevenueCat's customerInfo; the reward expiry comes from device-local storage (with verification, for reasons described later). They are composed and returned together.
// lib/adFree/store.tsimport { create } from 'zustand';import Purchases, { CustomerInfo } from 'react-native-purchases';import type { AdFreeState, AdFreeReason } from './types';import { saveRewardWindow } from './rewardWindow';interface AdFreeStore extends AdFreeState { applyCustomerInfo: (info: CustomerInfo) => void; grantRewardWindow: (durationMs: number) => Promise<void>; reevaluate: () => void;}// RevenueCat entitlement identifiers (as defined in the dashboard)const ENT_LIFETIME = 'lifetime_adfree';const ENT_SUBSCRIPTION = 'pro';function computeReason( hasLifetime: boolean, hasSubscription: boolean, rewardExpiresAt: number | null, now: number,): { reason: AdFreeReason; rewardExpiresAt: number | null } { if (hasLifetime) return { reason: 'lifetime', rewardExpiresAt: null }; if (hasSubscription) return { reason: 'subscription', rewardExpiresAt: null }; if (rewardExpiresAt && rewardExpiresAt > now) { return { reason: 'reward', rewardExpiresAt }; } return { reason: null, rewardExpiresAt: null };}export const useAdFreeStore = create<AdFreeStore>((set, get) => ({ reason: null, rewardExpiresAt: null, applyCustomerInfo: (info) => { const active = info.entitlements.active; const hasLifetime = ENT_LIFETIME in active; const hasSubscription = ENT_SUBSCRIPTION in active; const { rewardExpiresAt } = get(); set(computeReason(hasLifetime, hasSubscription, rewardExpiresAt, Date.now())); }, grantRewardWindow: async (durationMs) => { const expiresAt = await saveRewardWindow(durationMs); const info = await Purchases.getCustomerInfo(); const active = info.entitlements.active; set(computeReason( ENT_LIFETIME in active, ENT_SUBSCRIPTION in active, expiresAt, Date.now(), )); }, reevaluate: () => { const { rewardExpiresAt, reason } = get(); // lifetime / subscription do not change with time; only reward needs re-checking if (reason === 'reward') { set(computeReason(false, false, rewardExpiresAt, Date.now())); } },}));
On launch, feed customerInfo in once, and call the same function from RevenueCat's update listener. With this in place, every change — purchase, restore, subscription lapse — converges on the single entry point applyCustomerInfo.
// in app/_layout.tsx or your init siteuseEffect(() => { Purchases.getCustomerInfo().then(useAdFreeStore.getState().applyCustomerInfo); const sub = Purchases.addCustomerInfoUpdateListener( useAdFreeStore.getState().applyCustomerInfo, ); return () => sub.remove();}, []);
Fire the Timed Unlock Exactly at the Next Expiry
The trickiest part of the reward window is reflecting expiry on screen the moment it happens. The naive approach is a setInterval calling reevaluate() every second, but that is wasteful and bad for the battery. Ads are hidden for only tens of minutes at most; there is no need to run every second the whole time.
What I use instead is "set one timer, firing exactly at the next expiry." Every time the expiry updates, the timer is re-armed.
// lib/adFree/useRewardExpiryTimer.tsimport { useEffect } from 'react';import { AppState } from 'react-native';import { useAdFreeStore } from './store';export function useRewardExpiryTimer() { const reason = useAdFreeStore((s) => s.reason); const expiresAt = useAdFreeStore((s) => s.rewardExpiresAt); const reevaluate = useAdFreeStore((s) => s.reevaluate); useEffect(() => { if (reason !== 'reward' || !expiresAt) return; const fire = () => reevaluate(); const msUntilExpiry = expiresAt - Date.now(); if (msUntilExpiry <= 0) { fire(); return; } const timer = setTimeout(fire, msUntilExpiry); // setTimeout may be paused in the background, so re-check on resume const appSub = AppState.addEventListener('change', (state) => { if (state === 'active') reevaluate(); }); return () => { clearTimeout(timer); appSub.remove(); }; }, [reason, expiresAt, reevaluate]);}
The AppState listener is there for a reason. setTimeout is not guaranteed to fire while the app is backgrounded. If a user closes the app during a two-hour reward window and returns three hours later, setTimeout may not run at the promised time. Calling reevaluate() on every foreground resume guarantees the state converges to the correct value the instant they come back. In practice most of my app's sessions last a few minutes with frequent background round-trips, so without this resume-time re-check the expiry sometimes failed to show up on screen.
Route Every Ad Through One Hook
The state is now folded into one. The last step is to make every place that shows an ad go through this hook without exception. The opening bug happened precisely because I had not enforced that.
Then forbid placing a BannerAd directly on a screen; always go through this wrapper.
// components/AdSlot.tsximport { BannerAd, BannerAdSize } from 'react-native-google-mobile-ads';import { useAdFree } from '../lib/adFree/useAdFree';export function AdSlot({ unitId }: { unitId: string }) { const { isAdFree } = useAdFree(); if (isAdFree) return null; // any single reason to hide ads means no render return <BannerAd unitId={unitId} size={BannerAdSize.ADAPTIVE_BANNER} />;}
Interstitials and any other full-screen ads are the same: check useAdFreeStore.getState().reason !== null immediately before calling show(). As an operating rule I decided that "only AdSlot.tsx and the ad manager — two files — may import BannerAd and InterstitialAd directly," and a simple grep in CI rejects direct imports from anywhere else. I no longer rewrite the decision each time I add a screen, and the kind of miss from the opening no longer happens structurally.
Rewinding the Device Clock Keeps Ads Hidden Forever
From here are the potholes I hit in production. If you manage the reward expiry with Date.now() alone, you are defenseless against users who hand-set the device clock backward. After watching one ad for a two-hour unlock, advancing the date into the future makes the window read as expired — but rewinding it leaves the stored "future expiry" as an even more distant future, so ads stay hidden indefinitely.
The count is small, but without verification on the saved reward, a sliver of users produce sessions where eCPM stays pinned at zero. I closed it by also recording "the last time we checked" on save, and verifying on read that time has moved monotonically forward.
// lib/adFree/rewardWindow.tsimport AsyncStorage from '@react-native-async-storage/async-storage';const KEY = 'reward_window_v1';interface Stored { expiresAt: number; // unlock expiry (epoch ms) savedAt: number; // save time (for rewind detection)}export async function saveRewardWindow(durationMs: number): Promise<number> { const now = Date.now(); const expiresAt = now + durationMs; const payload: Stored = { expiresAt, savedAt: now }; await AsyncStorage.setItem(KEY, JSON.stringify(payload)); return expiresAt;}export async function loadRewardWindow(): Promise<number | null> { const raw = await AsyncStorage.getItem(KEY); if (!raw) return null; const { expiresAt, savedAt } = JSON.parse(raw) as Stored; const now = Date.now(); // device time earlier than savedAt = likely rewound. Invalidate the unlock. if (now < savedAt) { await AsyncStorage.removeItem(KEY); return null; } if (expiresAt <= now) { await AsyncStorage.removeItem(KEY); return null; } return expiresAt;}
If you want strictness, the expiry should be managed server-side, but adding a backend just for a reward unlock is overkill for an indie developer's cost-benefit, in my view. A device-local monotonic check alone closes benign rewinds (automatic time setting while traveling, for example) and most light tampering. What you are protecting is a few percent of leaked revenue, not a fortress. Weighed against backend maintenance cost, starting with local verification feels like the realistic choice.
Small Calls That Paid Off in Production
Three modest decisions that worked, worth writing down.
One is showing the remaining reward time in the UI. Displaying "ads are off for another 28 minutes" tells readers the unlock really took effect, and the completion rate of rewarded ads rose by roughly 1.3x in my app, by feel. The design choice of holding both the reason and the expiry in state pays off right here.
Two is keeping the computation in a pure function computeReason, so that calling applyCustomerInfo from both launch and the listener causes no harm. The same input always yields the same output, so even a duplicate notification right after a restore or a subscription lapse never corrupts the state.
Three is attaching the reason-for-no-ad straight into the AdMob-side logs. I can now separate an impression that did not show because it was "intentionally hidden by a subscription" from one that "failed to load," which made revenue variance easier to track across both Google Play and the App Store.
Start by counting, with grep, where your own app imports BannerAd directly. If even one screen does not go through AdSlot, that is your next candidate for a missed ad check. Folding the decision into one place frees you from going back to old code every time you add a monetization path.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.