The one idea
A route loader prefetches the data the route needs BEFORE it renders — and you point it at TanStack Query so the data is cached and the next visit is instant.
The router is the only thing that knows where the user is headed before content renders, so it is the
right place to kick off fetches. The loader runs on match (cause
enter / preload / stay), returns data read via
Route.useLoaderData(), and is gated for re-runs by loaderDeps. The
recommended pattern with TanStack Query: the loader merely primes the Query cache
(queryClient.ensureQueryData) and the component reads via useSuspenseQuery — so a
preloaded navigation lands at ~0ms.
1 · The load lifecycle — navigate, preload, change the deps
A pure state-machine simulation of the route loading lifecycle. Toggle defaultPreload, then
drive the route: navigate runs the loader (cause enter), a preloaded navigate
primes the cache first and lands at 0ms, and changing loaderDeps
(e.g. the page search param) re-runs the loader regardless of
staleTime. The gold-check above runs the identical pure functions over fresh states.
2 · Cold load vs preloaded — the latency race
Same route, same LATENCY = 280ms loader. On the left the user clicks cold (the loader
runs during the navigation — they wait). On the right the route was preloaded (loader already ran in
the background on hover) so the click is a cache hit at ~0ms. Hit run race to
animate both bars; toggle preload to flip the right lane between cold and preloaded.
cold navigate user waits
cause: enter) → renderlatency paid during navigation
preloaded navigate cache hit
cause: preload) →click → cache hit → render · ~0ms
3 · The recommended pattern — loader primes the Query cache
Per TkDodo (the Query author): treat the loader as an event handler. It fires
queryClient.ensureQueryData to start the fetch as early as possible (even before the component
bundle arrives), and the component reads via useSuspenseQuery. The loader does not
hand data to useLoaderData — Query needs Observers (from the hook) for refetch-on-focus,
invalidation and GC. Set defaultPreloadStaleTime: 0 so every preload primes the cache.
// 1. wire the QueryClient into router context (createRootRouteWithContext) const router = createRouter({ routeTree, context: { queryClient }, defaultPreloadStaleTime: 0, // ← preload always primes the Query cache }) // 2. the route loader PRIMES the cache (fire-and-forget event handler) export const Route = createFileRoute('/posts/$postId')({ loader: async ({ context, params }) => { await context.queryClient.ensureQueryData(postOptions(params.postId)) }, component: Post, }) // 3. the component READS via useSuspenseQuery (not useLoaderData!) function Post() { const { postId } = Route.useParams() const { data } = useSuspenseQuery(postOptions(postId)) // data: Post (never undefined) return <article>{data.title}</article> }
Approaches — when each runs, and where the cache lives
Three primitives, one recommended combo. The router cache is per-route (SWR); the Query cache is global and shared across routes — which is why the combo wins for data needed in more than one place.
loaderDeps / Query-integration API (verified against
tanstack.com/router and tkdodo.eu) — not a live router instance. Latencies are simulated constants, not real
network. Cross-refs:
🔗 navigation_links (preload triggers this loader) ·
🔗 router_type_safety (typed loader return) ·
🔗 path_search_params (search feeds loaderDeps).