A TanStack Router /products loader reads the page from location.search, and changing ?page=2 keeps showing page 1's data; why, and how do loaderDeps and staleTime fix it?
answer
- the cache key is not the URL
- match id: route, params, deps
- same match means no reload
- loaderDeps, then tune staleTime
basics
~20 sTanStack Router caches loader results per route match, keyed by route, path params and loaderDeps — not the search string. A search-only change keeps the same match, so the loader is not re-run. Declaring loaderDeps for page gives each page its own entry.
solid answer
~50 sTanStack Router has a built-in stale-while-revalidate cache for loaders. Each match is identified by the route id, the interpolated path params and a hash of whatever `loaderDeps` returns. A loader that reads `location.search` hides its dependency: `?page=1` and `?page=2` produce the **same match id**, and the router does not re-run a loader just because a matched route stayed in place with a different query string. Fix it with `loaderDeps: ({ search: { page } }) => ({ page })` and read `deps.page` in the loader; now each page is its own cache entry and a change of deps always reloads. Then tune freshness: `staleTime` defaults to 0, so revisiting a cached page renders it at once and revalidates in the background; `preloadStaleTime` defaults to 30 seconds; `gcTime` drops unused entries after 5 minutes. Return only the keys the loader uses, or unrelated params cause reloads.
code
tsx · 25 lines// src/routes/products.tsx
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
import { fetchProducts } from "../api";
export const Route = createFileRoute("/products")({
validateSearch: z.object({
page: z.number().int().min(1).catch(1).default(1),
viewMode: z.enum(["grid", "list"]).catch("grid").default("grid"),
}),
// Only what the loader uses: toggling viewMode will not refetch.
loaderDeps: ({ search: { page } }) => ({ page }),
loader: ({ deps: { page } }) => fetchProducts(page),
staleTime: 10_000, // cached pages count as fresh for 10 s
component: Products,
});
function Products() {
const products = Route.useLoaderData();
return <ProductGrid items={products} />;
}
function ProductGrid({ items }: { items: Array<{ id: string; name: string }> }) {
return <ul>{items.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}go deeper
Know that a TanStack Router loader should get search values through loaderDeps, not by reading the URL itself.
Explain the cache key — route, path params, loaderDeps — and the defaults: staleTime 0 with background revalidation, 30-second preload freshness, 5-minute gcTime.
Diagnose stale-looking pages from the match id, trim loaderDeps to what the loader uses, and choose staleTime, staleReloadMode and invalidate() deliberately.
Decide when the per-route loader cache is enough and when shared data belongs in a dedicated query cache that loaders only prime.
## The symptom A products route validates `page` with `validateSearch`, and its loader does `fetchProducts(Number(new URLSearchParams(location.search).get("page")))` using the `location` argument. Clicking "next page" updates the URL to `?page=2` and the pagination control, but the product grid still shows page 1. A hard reload of `?page=2` shows the right data. ## How TanStack Router identifies a loader's data TanStack Router ships a **route loader cache**: it stores each loader result, reuses it, revalidates it when stale, and garbage-collects it when unused. The unit of caching is a **route match**, and its id is built from three parts: 1. the **route id**, 2. the **interpolated path** — the path with its params filled in, such as `/products`, 3. a hash of the value returned by **`loaderDeps`**, if the route defines it. The search string is *not* part of the id. Search params reach the cache key only through `loaderDeps`. ## Why the loader does not re-run Going from `?page=1` to `?page=2`: - the route and its path are unchanged and there is no `loaderDeps`, so the **match id is identical**, - the match was already on screen, so this counts as the route *staying*, not *entering*, - with the default `staleTime` of 0 the data is stale, but the router reloads stale data when a route is entered, when you call `router.load()` or `router.invalidate()`, or when the match id changes and a new entry must load — not when the same match merely stays with a different query string. The loader's real dependency, `page`, was invisible to the router. The data reads correctly only on a fresh document load because the cache is empty then. ## The fix: declare the dependency - `loaderDeps: ({ search: { page } }) => ({ page })` — a pure function receiving the **validated** search params and returning the values the loader depends on, - `loader: ({ deps: { page } }) => fetchProducts(page)` — read them from `deps`, not from `location`. Now `?page=2` is a different match with its own cache entry. The docs state that when deps change between navigations, the route reloads **regardless of `staleTime`**. Return only what the loader uses: `loaderDeps: ({ search }) => search` makes an unrelated `viewMode` toggle refetch the grid. ## Tuning freshness once the key is right | Option | Default in the pinned source | Effect | |---|---|---| | `staleTime` | `0` ms | how long accepted navigation data counts as fresh; stale data renders immediately and revalidates in the background | | `preloadStaleTime` | 30 seconds | how long data from a preload counts as fresh for the navigation that follows | | `gcTime` / `preloadGcTime` | 5 minutes | how long unused entries are kept before pruning | | `staleReloadMode` | `'background'` | `'blocking'` waits for the stale reload instead of showing cached data first | | `shouldReload` | not set | boolean or function overriding the reload decision | | `defaultPreload` (router) | `false` | `'intent'` preloads on hover or touch, after `defaultPreloadDelay` (50 ms) | So with deps fixed, going back from page 2 to page 1 shows the cached page 1 instantly and refreshes it in the background — the stale-while-revalidate behaviour the router advertises. ## When to reach for something else The built-in cache is per route: it does not share or deduplicate data *between* routes, and it has no mutation or optimistic-update APIs. When the same product data is needed by several routes, the usual design is to have loaders prime a dedicated query cache and components read from it; that cache is its own topic. The loader-level lesson stays the same: every input a loader uses must be visible to the router, through params or `loaderDeps`. ## Debugging checklist 1. Does the loader read anything not in `params` or `deps` — `location`, globals, a store? 2. Does `loaderDeps` return exactly the keys the loader uses? 3. Is `staleTime` hiding an update you expected, or is `staleReloadMode` making revalidation invisible? 4. Would `router.invalidate()` after a mutation be the right trigger instead of a shorter `staleTime`?
- Why not simply set staleTime to 0 to force a reload on every change?`staleTime` is already 0 by default. Staleness only matters when the router considers reloading — on entering a route, on `router.load()` or `router.invalidate()`, or when the match id changes. Without `loaderDeps`, a search-only change keeps the same match, so no freshness setting helps.
- What goes wrong if loaderDeps returns the whole search object?Every search param becomes part of the cache key, so changing one the loader ignores — a view mode, a sort direction used only client-side — creates a new match and refetches. The docs warn against it: return only the values the loader actually uses.
saying these in an interview costs you the question
- TanStack Router re-runs a route's loader whenever any part of the URL changes
- Lowering staleTime makes a search-only change reload the loader
- Returning the whole search object from loaderDeps is the safe default
- Loader data is cached forever until the page reloads
- The router cache shares one entry across different routes that fetch the same data