TanStack Router moved data fetching into the route: declare a loader, and your component renders with the data already there. It's a genuinely great model โ typesafe search params, built-in caching, built-in race handling. But the very first paginated loader most people write silently never re-runs, the default pending behavior leaves your app blank for a full second, and the cache defaults refetch in one direction and preload in another โ all without a single console warning.
Everything below was reproduced and measured in a real Chrome on @tanstack/react-router 1.170.39 + React 19.3.0 (Vite, code-based routes) with an instrumented window.fetch before publishing. The numbers โ a pager frozen on page 1 while the URL says ?page=2, a blank root at 300ms, 99 fetches in 5 seconds from one innocent effect, exactly 1 AbortError โ are observed counts, not estimates.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=3"
That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:5173, StackBlitz, CodeSandbox, CI โ anywhere. When you want your own schema it's one curl or one click โ see ยง11.
Here is the paginated route almost everyone writes first โ typesafe search via validateSearch, fetch in the loader:
const pagerRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/pager',
validateSearch: (s) => ({ page: Number(s.page ?? 1) }),
loader: async ({ location }) => {
const page = new URLSearchParams(location.search).get('page') || 1
const r = await fetch(`${API}/products?page=${page}&limit=5`)
return r.json()
},
component: Pager, // renders useSearch().page + useLoaderData()
})
It looks right. It even works on first load. Measured, clicking a <Link search={{ page: 2 }}>:
?page=2, the header (from useSearch) dutifully shows page 2โฆ and the network log still shows 1 fetch, total. The list still shows page 1's records. Click page 3: same. Zero console warnings, zero errors.The rule the docs state and nobody reads: loaders only re-run when path params, loaderDeps, or cache freshness say so โ search params are not tracked unless you declare them. Reading location.search inside the loader works at call time but registers no dependency. Your header and your data now disagree, and every stale screenshot in your bug tracker starts here.
const pagerRoute = createRoute({
path: '/pager',
validateSearch: (s) => ({ page: Number(s.page ?? 1) }),
loaderDeps: ({ search }) => ({ page: search.page }), // โ the one addition
loader: async ({ deps }) => {
const r = await fetch(`${API}/products?page=${deps.page}&limit=5`)
return r.json()
},
})
Measured on the same click sequence (page 1 โ 2 โ 3): exactly 3 fetches, and the list follows the header (row ids 1โฆ, 6โฆ, 11โฆ). loaderDeps isn't boilerplate โ the deps object is part of the cache key, which is what makes ยง5's caching and preloading work per-page instead of per-path.
This is the TanStack Router cousin of a trap we've measured across ecosystems (Pinia Colada's static key, Angular's stale httpResource URL, NgRx's pre-called rxMethod): a dependency the framework can't see, failing silently.
Give the loader a realistic 1.5s response (the demo API simulates it with ?mock_delay=1500 โ no backend changes) and add a pendingComponent. Measured timeline on a cold navigation:
| t | what's on screen |
|---|---|
| 300ms | Nothing. Root innerText is empty โ not even the nav shell renders. |
| ~1200ms | pendingComponent finally appears. |
| ~1600ms | Data rendered (5 rows). |
Your spinner didn't show for the first second because defaultPendingMs is 1000: the router intentionally holds the previous screen (on first load: nothing) to avoid flashing a spinner on fast responses โ and once shown, defaultPendingMinMs (500) keeps it up to avoid a flicker. Sensible defaults, but on initial load they read as "the app is broken" for a full second. The fix is per-route or router-wide:
pendingMs: 0, // show pending immediately
pendingMinMs: 0, // release it as soon as data lands
Measured with those two lines: pending visible at 300ms. To see this timeline in your own app without a slow backend, point the loader at ?mock_delay=2000 and watch what your users watch.
Navigate to a loader route, away, and back. Measured with the default config:
| action | fetches (cumulative) |
|---|---|
| first visit | 1 |
| away โ back | 2 |
| away โ back again | 3 |
Every revisit refetched. Default staleTime is 0: cached data renders instantly (no pending flash โ it's stale-while-revalidate), but the loader re-runs in the background on every return. That's 30 identical requests from a user bouncing between a list and 30 detail pages. With staleTime: 60_000 on the route, measured: away โ back = still 1 fetch. Pick a number that matches how live your data really is โ and remember loaderDeps is the cache key, so each page of ยง3's pager caches separately.
With preload: 'intent' (on a Link or router-wide), measured on a link to a loader route:
This is the feature working as designed โ instant navigations feel fantastic. But know it's happening: loaders with side effects, request-counting analytics, per-request billing, and rate-limited APIs will all see traffic from users who never clicked. If your loader mutates anything, it shouldn't. (You can watch the hover fire in real time in your Mockbird project's request inspector.)
fetch doesn't reject on HTTP errors, and a loader is just an async function. This loader (simulating the outage with ?mock_status=500):
loader: async () => {
const r = await fetch(`${API}/products?limit=5&mock_status=500`)
return r.json() // no r.ok check
}
Measured: the route renders "successfully" and the screen shows the parsed error body โ {"error":"simulated 500 error (mock_status)"} โ as content, with zero console errors. If the component blindly does data.map(...), you instead get TypeError: data.map is not a function caught by the router's built-in boundary, a default "Something went wrong!" screen, and this verbatim console warning:
Warning: The following error wasn't caught by any route! At the very least,
consider setting an 'errorComponent' in your RootRoute!
The robust shape โ throw in the loader, catch in the route:
loader: async () => {
const r = await fetch(`${API}/products?limit=5`)
if (!r.ok) throw new Error('HTTP ' + r.status)
return r.json()
},
errorComponent: ({ error }) => <Oops message={error.message} />
Measured with mock_status=500: the errorComponent renders loader failed: HTTP 500. Drill the full matrix โ mock_status=401, 429, 503 โ against the demo API before your real API teaches you in production.
Classic setup: page 2's response takes 2s (mock_delay=2000), page 3's is instant. Click page 2, then page 3 150ms later. In a naive useEffect fetcher this ends with page 2's slow response clobbering page 3's screen โ we've measured that clobber in three other ecosystems. Measured here, loader with no abort handling:
What you do lose is bandwidth: the doomed 2-second request ran to completion. The router hands every loader a pre-wired controller โ pass it through:
loader: async ({ deps, abortController }) => {
const r = await fetch(url(deps), { signal: abortController.signal })
return r.json()
}
Measured on the same click sequence: exactly 1 AbortError (the page-2 request, cancelled the moment page 3's navigation superseded it), final screen still page 3. One property access, free bandwidth.
"I want this route to stay fresh," someone writes, and reaches for the router's cache-busting hammer inside the routed component:
function Products() {
const router = useRouter()
const data = useLoaderData({ from: '/products' })
useEffect(() => { router.invalidate() }) // โ no deps array
return <ul>โฆ</ul>
}
invalidate() marks the match stale and re-runs the loader โ new data โ re-render โ the deps-less effect runs again โ invalidate โ โฆ Measured: 39 fetches by the 2-second mark, 99 by the 5-second mark, 98 renders โ and the UI looks completely normal the whole time: the list is populated, there's zero console output. You find out from your API bill, or from the network tab you happened to leave open. (Point it at a Mockbird project and the per-project daily cap turns this into a visible 429 instead of a surprise invoice.) The boring fixes: an empty deps array, or better, ยง5's staleTime โ polling belongs in an interval, not a render effect.
Loader-level retry is easy to write and rarely tested against a real failure sequence. The demo API can serve a deterministic one โ mock_seq=503,503,200 returns those statuses in order per key:
loader: async () => {
for (let i = 0; i < 3; i++) {
const r = await fetch(`${API}/products?limit=5&mock_seq=503,503,200&mock_seq_key=run1`)
if (r.ok) return r.json()
await new Promise(res => setTimeout(res, 250 * (i + 1)))
}
throw new Error('gave up')
}
Measured: exactly 3 fetches โ 503, 503, then the list renders from the 200. The x-mockbird-seq response header (1/3, 2/3, 3/3) makes each attempt assertable in tests. Use a fresh mock_seq_key per run to restart the sequence.
curl -X POST "https://mockbird.mockbird.workers.dev/api/projects" \
-H "content-type: application/json" \
-d '{"preset": "ecommerce"}'
That returns a live base URL with seeded products, orders, customers, reviews โ full CRUD, filters (?price_gte=100), pagination with X-Total-Count, relations (?_expand=customer), GraphQL, OpenAPI + TypeScript/Zod exports, and every simulation flag used above (mock_status, mock_delay, mock_seq, envelope reshaping, chaos). Or skip the terminal: one-click create / import your own spec or data. Free, no signup required, eject anytime.
Written by the Mockbird maker โ bias disclosed. TanStack Router comes out of these measurements looking good: the race was unloseable by design (ยง8, 0 aborts and still no clobber), preloading worked exactly as advertised, and the caching model is coherent once you know loaderDeps is the key. The traps are the untracked search param (silent), the 1-second pending hold that reads as a blank app, defaults that refetch on every revisit, and the universal fetch-doesn't-reject 500 hole โ each a one-line fix once you can see it. If you're pairing the router with TanStack Query for data (a combination the router docs themselves recommend for complex apps), the Query-side failure shapes are measured in our TanStack Query guide. Measurements here were taken Sep 23, 2026 on @tanstack/react-router 1.170.39, React 19.3.0, Vite 5, code-based routes; file-based routing generates the same route options, and versions move โ the failure shapes are what to remember. Related: mocking for React, React 19 use(), measured, SolidJS createResource, measured.