Tags react native modalonboardingreact nativereact navigationmobile app bugstouch handling
All posts

react native modal

A React Native Modal always wins: the new-user soft lock

The onboarding button did nothing and the app looked frozen. A React Native Modal is a separate window, so it swallows touches from everything below.

August 11, 2026 6 min

The report came in like this:

I just opened the app and the “Got it” button does nothing. It seems frozen.

The onboarding card is on screen. The button is right there, clearly rendered. Tapping it does nothing. Back does nothing.

No crash. No error. The app is running fine. It simply does not accept touches.

A React Native Modal is not part of your screen

The cause was a React Native Modal: an invisible one, sitting directly on top of the onboarding card.

The word “on top” is doing real work there. Inside a screen, you decide what stacks above what - reorder the tree, adjust elevation, done. A React Native Modal is outside that system. Opening one asks the OS for a new window, and that window covers your entire app surface. It is not an element in your tree that happens to be last. It is a separate sheet of glass laid over the whole thing.

Which produces exactly this:

  • Nothing inside your screen can be raised above it. There is no z-index that reaches.
  • If the modal is transparent, everything underneath stays fully visible.
  • Touches still go to the window on top.

Visible and tappable have come apart. To the user a perfectly rendered button is not responding, and that does not read as “this button is broken” - it reads as “the app froze.”

In my case the invisible window was a bottom sheet asking the user to plan their day. Same screen, opened automatically, at exactly the same moment as the onboarding card.

// This sheet is a Modal underneath. That means: separate window.
<Modal visible={visible} animationType="slide" transparent statusBarTranslucent>

transparent is why nothing looked wrong. The screen underneath renders normally, so there is nothing to see - only something to fail to touch.

Reading the code does not reveal it

This survived a long time for a structural reason: the two pieces do not know about each other.

The onboarding card is JSX inside the screen. The sheet is a separate component in a separate file. Read either one in isolation and both are correct. Neither contains the fact that they can be on screen simultaneously, because “do not open the sheet while the guide is up” was a constraint nobody had written - I did not know it needed to exist.

And both really are rendered. Nothing is hidden, nothing failed to mount, no layout is broken. Two correct components, one of which quietly holds the entire touch surface.

The fix is ordering, not layering

Trying to out-layer a Modal is not an option, so the sheet has to wait:

// Hold off while the guide is up. This sheet is an RN Modal (separate window),
// so it sits above the guide (a plain absolute View) and takes every touch.
// The user reads that as "the confirm button is dead and the app is frozen."
if (showGuide) return;

There is a subtlety in when that flag gets its value.

Originally the app decided whether to show the guide after the first render - read the stored flag, and if the user has not seen it, switch it on. The problem is that the sheet’s effect runs in that same pass:

  1. Screen renders. showGuide is still false.
  2. The sheet effect runs, sees false, passes the guard, opens the sheet.
  3. Then the guide turns on.

The guard exists and is bypassed - not because the logic is wrong, but because at the single moment it mattered the condition had not been established yet.

So the value is now decided before the first render:

// Decide synchronously. Flipping this on later means the sheet effect
// has already run and passed the guard.
const [showGuide, setShowGuide] = useState(
  () => !(DeviceStore.get<boolean>(CacheKeys.GUIDE_TIMELINE_V1_DONE) ?? false),
);

That works because the flag comes from device storage and is available immediately. If it had to come from the network, this approach fails, and the correct shape is a third state - unknown - during which nothing auto-opens. A boolean cannot express “not yet,” and defaulting “not yet” to false is precisely what caused the bug.

An invisible screen was also opening sheets

The same investigation turned up a second path to the same symptom.

This app uses bottom tabs, configured to keep inactive screens mounted. That is deliberate - remounting on every tab switch is slow and loses scroll position and local state.

But “not visible” is not “not running.” An off-screen tab executes its effects normally.

So on cold start, while the user is looking at a completely different tab, this screen would finish loading its async data and open its sheet. From the user’s side, a sheet erupts on a screen that has nothing to do with it.

// Only open when this tab is actually on screen.
const isFocused = useIsFocused();
...
if (!isFocused) return;

That raised its own decision: when unfocused, skip or defer?

Skipping loses the sheet for the whole day, especially if the “shown today” marker was already written. So it defers - the effect re-evaluates when the tab gains focus, and the marker is only written after every guard has passed. Bailing out early would have quietly consumed today’s only chance to show it.

Why this hit new users exclusively

The trigger is a single condition: the onboarding card and an auto-opening sheet present at the same time.

The card appears once, ever. Existing users cannot produce the combination. Neither could I - my install had dismissed it months earlier.

New users hit it at a 100% rate. Creating a goal navigates straight to this screen, the card appears on arrival, and the day-planning sheet opens in the same instant.

So the bug was invisible to everyone already using the app and unavoidable for everyone opening it for the first time - at the exact moment that decides whether they stay.

First-task completion was near zero at the time. I cannot tell you how much of that was this, and I am not going to pretend the number is attributable. What I can say is that while a first-run user cannot tap anything on their first screen, the funnel numbers after that point are not measuring what they claim to measure.

What to take away

A React Native Modal does not compete with your view tree - it sits above it. If a screen renders its own overlay and also auto-opens a modal, those two need an explicit order. The trap is that both can be genuinely “visible” at once, so nothing looks wrong.

A guard must be established before the thing it guards runs. A flag that turns on after the first render is absent during the only render where it mattered.

Off-screen does not mean idle. Any effect that opens UI automatically should also confirm its screen is actually focused.

States that occur exactly once are the ones the author never sees. Add a developer-menu switch that resets them. After this, I added one that clears the onboarding-card flag - one tap and I am a new user again.

The scheduled notification post from the same app is the same species of bug: no error is thrown, only the human experience goes wrong.

Measured 2026-08-11 on React Native 0.85, Expo SDK 56, React Navigation 7, Android. The separate-window behaviour of Modal applies to iOS as well; whether inactive screens stay mounted depends on your navigator configuration.

Previous Scheduled notifications outlive the account that created them