Most React modals start with one innocent boolean. Then the requirements arrive: Back should close it, refresh should not break it, a copied URL should reopen it, and the screen underneath should keep its scroll position.
The usual response is to lift the boolean into a parent, then into Zustand or Redux, then bolt history synchronization onto the side. That is how a modal becomes a second, worse router.
Here is the part most implementations get wrong: if people expect Back, refresh, or a shared URL to remember it, it is not component state. It is a route that may happen to look like a modal.
The modal is presentation. The destination is navigation. TanStack Router, Next.js, and Expo Router use different primitives, but the durable pattern is the same.
The mistake starts with one innocent boolean
An overlay built only with component state can show and hide pixels:
const [open, setOpen] = useState(false);The boolean is not wrong. The classification is. It works for a tooltip or confirmation, but it collapses when the content has destination identity and must answer what Back means, what a shared URL renders, or which screen should return after dismissal.
A contextual route transition does three things together:
- Change navigation to the destination.
- Preserve an existing origin or construct a deliberate base.
- Compose the destination without confusing that base with destination identity.
Closing follows the entry state:
- Preserved origin: go back to the real route already in history.
- Synthesized base: dismiss to the base that the application deliberately placed beneath the destination.
- Standalone destination: navigate to an explicit fallback, or provide no close affordance when the destination is an ordinary page.
For a standalone fallback, choose push or replace deliberately. Replacing consumes the direct entry; pushing lets Back reopen it.
Close should reverse the navigation that opened the experience, not merely hide its pixels.
Zustand and Redux do not fix the mistake
Lifting open into a parent looks like progress. When distant components need to open the same experience, putting it in Zustand, Redux, or another global store can feel inevitable.
That solves reach, not navigation semantics. The store still cannot explain:
- why Back should close the destination;
- what a copied URL should render;
- whether a refresh restores an overlay or a standalone page;
- which origin, scroll position, and focus target should return.
Once the store starts mirroring the pathname, history entry, and previous screen, it has become a second router. The two sources can disagree: navigation changes while the store remains open, a deep link arrives while the store is empty, or the parent that owned the preserved context has already unmounted.
Let the router own destination identity and entry state. A parent or global store can still own durable data used inside the destination—a cart, draft, or selected entity—but not whether that destination currently exists in navigation.

The fix: one destination, three restoration paths
The same destination route can have three valid entry states:
| Entry state | Composition | Close behavior |
|---|---|---|
| Preserved origin | Keep the existing screen mounted and render the destination alongside it | Go back to the preserved route |
| Synthesized base | Construct a known base beneath the destination | Dismiss to the constructed base |
| Standalone destination | Let the destination own the screen | Use an explicit fallback or omit the close affordance |
The useful distinction is not modal versus page. It is whether the destination arrived with a real origin, a deliberately constructed base, or no surrounding route at all.
The route identifies the destination. Entry context decides how that destination joins the interface. /detail can be a layer over a preserved list, a sheet over a reconstructed base, or a standalone detail screen without any version lying about the URL.
This separation gives the route one stable meaning:
/contactidentifies the contact experience;/photos/42identifies a photograph;/places/juniper-lookoutidentifies a place.
Whether that destination occupies a dialog, sheet, side panel, or full screen is composition.
The payoff is the context you do not rebuild
The value of this pattern is not the overlay itself. It is the state that never needs to be reconstructed because the originating screen remains mounted.
That can include:
- scroll position;
- filters and sorting;
- a selected map region;
- loaded data and pagination;
- draft input;
- the element that should regain focus.
Preservation should still be tied to the contextual transition. A normal page navigation should usually begin at the top. A direct visit should not inherit stale selection or scroll merely because the same pathname once appeared as an overlay.
Presentation can change without changing the navigation contract. The same destination may be a desktop dialog, a mobile bottom sheet, or a full screen on a narrow device. The route, direct-entry behavior, and dismissal rule remain stable while the container changes.
The test most modal implementations fail
The easiest way to expose a weak implementation is to paste the destination URL into a new tab or launch the app from its deep link.
A destination opened without an existing origin needs one of two honest answers:
- Canonical composition: the destination owns the screen.
- Known base composition: the application constructs a deliberate screen beneath it.
What it cannot rely on is an invisible previous page. Browser history may belong to another site. A native app may have launched from a terminated state. A refreshed route may no longer carry the composition that originally opened it.
Designing this path first clarifies the invariant: context may improve presentation, but the route must work without preserved context.
Three routers, one uncomfortable conclusion
Each router encodes the pattern at a different layer:
| Router | Contextual composition | Direct-entry composition | Close result |
|---|---|---|---|
| TanStack Router | Keep the runtime location and mask the visible route | Unmask and resolve the visible route normally | Back to the preserved route, or a known fallback |
| Next.js | Intercept client navigation into a parallel slot | Render the canonical route | Back from the slot; ordinary navigation from the page |
| Expo Router | Present the destination over the current stack | Reconstruct a known base with an anchor | Dismiss to the existing or anchored base |
TanStack Router: carry context in history
This site's contact experience keeps Home, About, or Writing mounted while the address bar becomes /contact:

<Link
mask={{ to: "/contact", unmaskOnReload: true }}
resetScroll={false}
state={(previous) => ({
...previous,
contactFocus,
contactOverlay: true,
})}
to="."
/>The runtime location preserves the current page. The masked location gives contact a shareable identity. Because to="." still matches the origin route, the contextual transition does not render the /contact route component or run its loader. A shared root layout observes contactOverlay and composes the overlay above the matched origin.
unmaskOnReload removes that temporary composition context after a reload so /contact resolves as a normal route. The shared layout then distinguishes a contextual open from a cold start:
const masked = location.state.contactOverlay === true;
const coldStart = location.pathname === "/contact" && !masked;When masked, closing goes back. On a cold start, closing navigates to Home because no preserved page exists.
Next.js: compose the destination in a slot
A gallery can keep its grid mounted while a selected photo renders in a parallel slot. The same photo URL still owns a canonical page for direct requests.

app/(gallery)/
@photo/
(.)photos/[id]/page.tsx
[...catchAll]/page.tsx
default.tsx
photos/[id]/page.tsx
layout.tsx
page.tsxThe interceptor places a client-side photo navigation into the slot, but the directory alone renders nothing: layout.tsx must accept photo and render it alongside children. A direct request bypasses the interception and renders photos/[id]/page.tsx.
The two null routes keep the composition honest. default.tsx is the hard-navigation fallback when Next cannot recover the slot's active state. [...catchAll]/page.tsx clears the slot during client navigation away; without it, the previously active photo can remain composed after the route no longer calls for it.
Expo Router: reconstruct a known base
On native, a place route can open as a sheet over a map. Selecting the place preserves the active map. Opening the same deep link from a terminated state reconstructs a known map base first.

export const unstable_settings = { anchor: "(tabs)" };
<Stack>
<Stack.Screen name="(tabs)" />
<Stack.Screen
name="(place-detail)"
options={{ presentation: "transparentModal" }}
/>
</Stack>;transparentModal keeps the previous route visible while the place route renders its own sheet and backdrop. The anchor supplies a deliberate base when a deep link starts without an existing stack, so dismissal reveals that constructed base rather than guessing at history.
Where the “simple modal” starts leaking
Most failures happen when an implementation knows the destination but has not modeled how it was entered.
Restoration depends on entry context
The pathname alone cannot tell whether a detail route replaced the previous screen or appeared alongside it. Preserve scroll and focus only when navigation state says the transition entered or left the contextual composition.
History is not a fallback
An in-app overlay can close with back(). A standalone visit, refresh, or deep link needs an explicit destination because there may be no useful previous screen to reveal. An anchored direct entry is different: the application deliberately created the previous screen.
Parallel slots must be cleared
A router may preserve the last active slot across client transitions. Define the unmatched state explicitly so leaving the destination also removes its parallel presentation.
Interceptors need boundaries
A broad dynamic segment can intercept reserved paths as well as entity slugs. For example, a [slug] interceptor under /photos can also catch /photos/new. Exclude reserved paths before rendering the interceptor; redirecting to the same URL afterward may simply enter the interception again.
Each failure points back to the same model: route identity, entry state, composition, and reversal must agree.
The rule is brutally simple
A simple test helps:
If the visitor would expect Back, Forward, or a deep link to remember it, make it navigation.
Use a route when the experience:
- has a shareable or restorable identity;
- should participate in navigation history;
- needs a direct-entry composition;
Keep local component state when the interaction:
- is temporary and meaningless outside the current screen;
- should disappear on navigation;
- does not deserve a URL or deep link.
A tooltip is state. A photo is a route. A destructive confirmation is state. A cart may be either, depending on whether it must survive navigation and support direct entry.
The checklist most modal implementations skip
Before choosing an API, then again before shipping, verify:
- The destination has one stable route identity.
- Its entry state is preserved, synthesized, or standalone.
- Refreshes, cold launches, and deep links render a deliberate composition.
- Closing restores the real origin, reveals the constructed base, or uses an explicit standalone policy.
- Scroll and focus return only when useful context was preserved.
- Responsive presentation does not change navigation semantics.
- Parallel slots clear when the destination is no longer active.
- Dynamic interceptors cannot swallow unrelated routes.
Stop managing destinations like booleans
If it needs history, restoration, or a URL, stop managing it like UI state. Make it a route, then decide whether that route appears as an overlay or owns the screen.
