No-flash navigation
September 15, 2026 · View on GitHub
Changing routes never clears the current page and never shows a loading state in its place. The next page replaces the previous one in a single DOM commit, already holding its data. This document describes the mechanism that makes that the default and the one helper a route uses to stay on it.
How a navigation works
sequenceDiagram participant U as User participant R as client-router participant L as route loader participant P as navigation-data slot participant C as route component U->>R: click same-origin link R->>L: loader(url) (intent prefetch may already be in flight) Note over R: previous page stays on screen<br/>NavigationProgress bar after 150ms L-->>R: payload R->>P: setPreloadedNavigationData(href, payload) R->>R: pushState + notify() R->>C: one re-render with the new href C->>P: createRouteData.read() consumes the payload C-->>U: previous article replaced in one commit
packages/worker/client/client-router.tsxintercepts the click and runs the destination's loader fromclientRouteLoaders(packages/worker/client/routes/index.tsx) before it touches the URL. Hover, focus, and touch start that loader early (intent-prefetch.ts), and the lazy route chunk is warmed alongside it (lazy-route.tsx).- While the loader runs, nothing about the page changes. The only pending UI is
the 3px
NavigationProgressbar, and only when the navigation is still pending after 150ms. - The loader result is parked in the single-slot navigation store
(
navigation-data.ts), the URL is committed, and the tree re-renders once. - The route's
createRouteData(...).read(handle, currentHref)consumes that slot (or the SSR-embedded payload on the first document) and returnskind: 'ready'with the new payload. The previous content and the new content are never on screen together and no intermediate state exists.
Persistent shells (/account, /admin, /docs) additionally skip the page
view transition so their rail does not re-animate. Same-package Repo / Files /
Settings hops and /@username chip and search (q) query changes skip it too —
shouldUseViewTransition in client-router.tsx owns those lists, and
prefers-reduced-motion disables transitions everywhere.
Writing a route
Use createRouteData from #client/route-data.tsx. It owns consume-once of the
loader payload, the fallback fetch and its href latch, abort handling, and the
last-good payload the route keeps rendering while any fallback runs.
import {
createRouteData,
renderRoutePendingStatus,
} from '#client/route-data.tsx'
export function ThingRoute(handle: Handle) {
const thingData = createRouteData({
key: 'thing', // AppLoaderData key returned by thingRouteLoader
async load(href, signal) {
const response = await fetch(apiHrefFor(href), { signal })
if (response.status === 404) return null // -> kind: 'not-found'
const payload = await readJson<ThingLoaderData>(response)
if (!response.ok || !payload?.ok) throw new Error('Unable to load.') // -> 'error'
return payload
},
})
return () => {
const currentHref = readCurrentRouterHref(handle)
const snapshot = thingData.read(handle, currentHref)
if (snapshot.kind === 'not-found') return <NotFound />
if (snapshot.kind === 'error') return <LoadError />
const thing = snapshot.data
// Only the SPA cold path (no SSR payload, nothing loaded before) has
// nothing to keep on screen.
if (thing === null) return <p role="status">Loading…</p>
const pending = snapshot.kind === 'pending'
return (
<article aria-busy={pending ? 'true' : undefined}>
{pending ? renderRoutePendingStatus() : null}
<h1>{thing.title}</h1>
</article>
)
}
}
Snapshot meanings:
ready:datadescribes the current location. This is every navigation on the happy path.pending: a fallback fetch for the current location is in flight.datais the last good payload — the previous page whenstaleis true — and the route keeps rendering it underaria-busywith the visually hiddenrenderRoutePendingStatus()live region so assistive tech hears the update without the layout moving. Fallback fetches happen when the router's loader failed, after a stale refresh (a form POST redirecting back to the same URL), or on a cold SPA mount without SSR data.not-found/error: the fallback fetch for the current location settled that way (loadreturnednull, or threw — the thrown error issnapshot.error).
load may also return routeDataRedirect('/login') when the fetch answered
401: the route keeps its current content while the browser leaves for the login
document.
Register the component and its loader in
packages/worker/client/routes/index.tsx under the same routePattern(...)
key; the loader is what lets the router load-before-commit. Reuse one component
for sibling patterns (list and detail, /docs and /docs/:slug) so remix/ui
keeps the instance and its last-good payload across the switch.
Routes that own state (account, admin)
Account and admin pages hold forms, selections, and action feedback in their closure and mutate that state from POST responses. They use the same helper and apply a payload into that state when its identity changes:
let appliedPayload: AccountJobsLoaderData | null = null
let appliedError: Error | null = null
const jobsData = createRouteData({
key: 'accountJobs',
locationKey: getDataLatchKey, // list and detail hrefs share one payload
async load(href, signal) {
const response = await fetch(buildJobsApiRequestUrl(href), { signal })
if (response.status === 401) return routeDataRedirect('/login')
const payload = await readJson<AccountJobsLoaderData>(response)
if (!response.ok || !payload?.ok)
throw new Error('Unable to load scheduled jobs.')
return payload
},
})
return () => {
const currentHref = readCurrentRouterHref(handle)
const snapshot = jobsData.read(handle, currentHref)
if (snapshot.data && snapshot.data !== appliedPayload) {
appliedPayload = snapshot.data
applyPayload(snapshot.data)
}
if (snapshot.error && snapshot.error !== appliedError) {
appliedError = snapshot.error
setMessage(snapshot.error.message, 'error')
}
const pending = snapshot.kind === 'pending'
return (
<AccountManagementShell busy={pending && appliedPayload !== null}>
{/* header, message, and the page — rendered from closure state */}
</AccountManagementShell>
)
}
AccountManagementShell busysetsaria-busyand renders the hidden live region for every account/admin page in one place.- Loaders that return several keys assemble them with a
consume(handle, href)option instead ofkey(all-or-nothing: returnnullwhen a required key is missing so the fallback fetch loads the full set). locationKey(href)names which hrefs share one payload (a list page and its detail pages, filters that the payload ignores).reload(handle, currentHref)refetches the current location after a mutation the route wants to reconcile with the server; the current payload stays on screen underpendinguntil the fresh one lands.- A standalone "Loading…" paragraph is only correct when
appliedPayload === null— nothing has ever rendered here.
When a section is slow
Keep the ordering: make the loader faster, then cache it, then split the slow part out. A slow section is not a reason to render the page early with a spinner where content goes.
- Speed: the loader payload is what the navigation waits on. Trim it to what the route renders; move expensive derivations server-side.
- Cache: intent prefetch already starts the loader on hover/focus.
prefetchRouteHrefswarms a list of destinations on render (the equivalent ofprefetch="render") for links the visitor is likely to click next. Destinations that share a matcher and not a payload (every/docs/:slug) must pass{ independent: true }— a shared warmup would attach one article JSON to every slug. The docs shell does that for every sidebar href after hydrate (DocsNavPrefetch). - Stream / defer: when one region is genuinely slow and the rest is not,
split it into its own loader key or a Remix
<Frame>(see frames) with afallbackthat reserves the region's height, so the stable chrome commits at once and only that region fills in later. Keep the region's box fixed so the fill-in does not shift layout.
What the checks cover
packages/worker/client/route-data.node.test.tsandroute-load-latch.node.test.tspin the data-continuity contract: preloaded payloads are applied without a refetch, previous payloads stay while a fallback runs, aborted fetches re-arm, and late completions are dropped.e2e/docs.spec.tsande2e/account-navigation.spec.tsobserve<main>through guide-to-guide and account/admin section clicks (e2e/main-transitions.ts) and fail on any intermediate DOM state without an<h1>, any loading copy where content was, or a repeated request for a payload the router already loaded.createRouteLoadLatchis the helper's internal latch; client routes read throughcreateRouteDataand do not hand-roll load bookkeeping.