@tanstack/router-core 1.167.3 → 1.167.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,485 @@
1
+ ---
2
+ name: router-core/data-loading
3
+ description: >-
4
+ Route loader option, loaderDeps for cache keys, staleTime/gcTime/
5
+ defaultPreloadStaleTime SWR caching, pendingComponent/pendingMs/
6
+ pendingMinMs, errorComponent/onError/onCatch, beforeLoad, router
7
+ context and createRootRouteWithContext DI pattern, router.invalidate,
8
+ Await component, deferred data loading with unawaited promises.
9
+ type: sub-skill
10
+ library: tanstack-router
11
+ library_version: '1.166.2'
12
+ requires:
13
+ - router-core
14
+ sources:
15
+ - TanStack/router:docs/router/guide/data-loading.md
16
+ - TanStack/router:docs/router/guide/deferred-data-loading.md
17
+ - TanStack/router:docs/router/guide/router-context.md
18
+ - TanStack/router:docs/router/guide/data-mutations.md
19
+ ---
20
+
21
+ # Data Loading
22
+
23
+ ## Setup
24
+
25
+ Basic loader returning data, consumed via `useLoaderData`:
26
+
27
+ ```tsx
28
+ // src/routes/posts.tsx
29
+ import { createFileRoute } from '@tanstack/react-router'
30
+
31
+ export const Route = createFileRoute('/posts')({
32
+ loader: () => fetchPosts(),
33
+ component: PostsComponent,
34
+ })
35
+
36
+ function PostsComponent() {
37
+ const posts = Route.useLoaderData()
38
+ return (
39
+ <ul>
40
+ {posts.map((post) => (
41
+ <li key={post.id}>{post.title}</li>
42
+ ))}
43
+ </ul>
44
+ )
45
+ }
46
+ ```
47
+
48
+ In code-split components, use `getRouteApi` instead of importing Route:
49
+
50
+ ```tsx
51
+ import { getRouteApi } from '@tanstack/react-router'
52
+
53
+ const routeApi = getRouteApi('/posts')
54
+
55
+ function PostsComponent() {
56
+ const posts = routeApi.useLoaderData()
57
+ return <ul>{/* ... */}</ul>
58
+ }
59
+ ```
60
+
61
+ ## Route Loading Lifecycle
62
+
63
+ The router executes this sequence on every URL/history update:
64
+
65
+ 1. **Route Matching** (top-down)
66
+ - `route.params.parse`
67
+ - `route.validateSearch`
68
+ 2. **Route Pre-Loading** (serial)
69
+ - `route.beforeLoad`
70
+ - `route.onError` → `route.errorComponent`
71
+ 3. **Route Loading** (parallel)
72
+ - `route.component.preload?`
73
+ - `route.loader`
74
+ - `route.pendingComponent` (optional)
75
+ - `route.component`
76
+ - `route.onError` → `route.errorComponent`
77
+
78
+ Key: `beforeLoad` runs before `loader`. `beforeLoad` for a parent runs before its children's `beforeLoad`. Throwing in `beforeLoad` prevents all children from loading.
79
+
80
+ ## Core Patterns
81
+
82
+ ### loaderDeps for Search-Param-Driven Cache Keys
83
+
84
+ Loaders don't receive search params directly. Use `loaderDeps` to declare which search params affect the cache key:
85
+
86
+ ```tsx
87
+ // src/routes/posts.tsx
88
+ import { createFileRoute } from '@tanstack/react-router'
89
+
90
+ export const Route = createFileRoute('/posts')({
91
+ validateSearch: (search) => ({
92
+ offset: Number(search.offset) || 0,
93
+ limit: Number(search.limit) || 10,
94
+ }),
95
+ loaderDeps: ({ search: { offset, limit } }) => ({ offset, limit }),
96
+ loader: ({ deps: { offset, limit } }) => fetchPosts({ offset, limit }),
97
+ })
98
+ ```
99
+
100
+ When deps change, the route reloads regardless of `staleTime`.
101
+
102
+ ### SWR Caching Configuration
103
+
104
+ TanStack Router has built-in Stale-While-Revalidate caching keyed on the route's parsed pathname + `loaderDeps`.
105
+
106
+ Defaults:
107
+
108
+ - **`staleTime`: 0** — data is always considered stale, reloads in background on re-match
109
+ - **`preloadStaleTime`: 30 seconds** — preloaded data won't be refetched for 30s
110
+ - **`gcTime`: 30 minutes** — unused cache entries garbage collected after 30min
111
+
112
+ ```tsx
113
+ export const Route = createFileRoute('/posts')({
114
+ loader: () => fetchPosts(),
115
+ staleTime: 10_000, // 10s: data considered fresh for 10 seconds
116
+ gcTime: 5 * 60 * 1000, // 5min: garbage collect after 5 minutes
117
+ })
118
+ ```
119
+
120
+ Disable SWR caching entirely:
121
+
122
+ ```tsx
123
+ export const Route = createFileRoute('/posts')({
124
+ loader: () => fetchPosts(),
125
+ staleTime: Infinity,
126
+ })
127
+ ```
128
+
129
+ Globally:
130
+
131
+ ```tsx
132
+ const router = createRouter({
133
+ routeTree,
134
+ defaultStaleTime: Infinity,
135
+ })
136
+ ```
137
+
138
+ ### Pending States (pendingComponent / pendingMs / pendingMinMs)
139
+
140
+ By default, a pending component shows after 1 second (`pendingMs: 1000`) and stays for at least 500ms (`pendingMinMs: 500`) to avoid flash.
141
+
142
+ ```tsx
143
+ export const Route = createFileRoute('/posts')({
144
+ loader: () => fetchPosts(),
145
+ pendingMs: 500,
146
+ pendingMinMs: 300,
147
+ pendingComponent: () => <div>Loading posts...</div>,
148
+ component: PostsComponent,
149
+ })
150
+ ```
151
+
152
+ ### Router Context with createRootRouteWithContext (Factory Pattern)
153
+
154
+ `createRootRouteWithContext` is a factory that returns a function. You must call it twice — the first call passes the generic type, the second passes route options:
155
+
156
+ ```tsx
157
+ // src/routes/__root.tsx
158
+ import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
159
+
160
+ interface MyRouterContext {
161
+ auth: { userId: string }
162
+ fetchPosts: () => Promise<Post[]>
163
+ }
164
+
165
+ // NOTE: double call — createRootRouteWithContext<Type>()({...})
166
+ export const Route = createRootRouteWithContext<MyRouterContext>()({
167
+ component: () => <Outlet />,
168
+ })
169
+ ```
170
+
171
+ Supply the context when creating the router:
172
+
173
+ ```tsx
174
+ // src/router.tsx
175
+ import { createRouter } from '@tanstack/react-router'
176
+ import { routeTree } from './routeTree.gen'
177
+
178
+ const router = createRouter({
179
+ routeTree,
180
+ context: {
181
+ auth: { userId: '123' },
182
+ fetchPosts,
183
+ },
184
+ })
185
+ ```
186
+
187
+ Consume in loaders and beforeLoad:
188
+
189
+ ```tsx
190
+ // src/routes/posts.tsx
191
+ export const Route = createFileRoute('/posts')({
192
+ loader: ({ context: { fetchPosts } }) => fetchPosts(),
193
+ })
194
+ ```
195
+
196
+ To pass React hook values into the router context, call the hook above `RouterProvider` and inject via the `context` prop:
197
+
198
+ ```tsx
199
+ import { RouterProvider } from '@tanstack/react-router'
200
+
201
+ function InnerApp() {
202
+ const auth = useAuth()
203
+ return <RouterProvider router={router} context={{ auth }} />
204
+ }
205
+
206
+ function App() {
207
+ return (
208
+ <AuthProvider>
209
+ <InnerApp />
210
+ </AuthProvider>
211
+ )
212
+ }
213
+ ```
214
+
215
+ Route-level context via `beforeLoad`:
216
+
217
+ ```tsx
218
+ export const Route = createFileRoute('/posts')({
219
+ beforeLoad: () => ({
220
+ fetchPosts: () => fetch('/api/posts').then((r) => r.json()),
221
+ }),
222
+ loader: ({ context: { fetchPosts } }) => fetchPosts(),
223
+ })
224
+ ```
225
+
226
+ ### Deferred Data Loading
227
+
228
+ Return unawaited promises from the loader for non-critical data. Use the `Await` component to render them:
229
+
230
+ ```tsx
231
+ import { createFileRoute, Await } from '@tanstack/react-router'
232
+
233
+ export const Route = createFileRoute('/posts/$postId')({
234
+ loader: async ({ params: { postId } }) => {
235
+ // Slow data — do NOT await
236
+ const slowDataPromise = fetchComments(postId)
237
+ // Fast data — await
238
+ const post = await fetchPost(postId)
239
+
240
+ return { post, deferredComments: slowDataPromise }
241
+ },
242
+ component: PostComponent,
243
+ })
244
+
245
+ function PostComponent() {
246
+ const { post, deferredComments } = Route.useLoaderData()
247
+
248
+ return (
249
+ <div>
250
+ <h1>{post.title}</h1>
251
+ <Await
252
+ promise={deferredComments}
253
+ fallback={<div>Loading comments...</div>}
254
+ >
255
+ {(comments) => (
256
+ <ul>
257
+ {comments.map((c) => (
258
+ <li key={c.id}>{c.body}</li>
259
+ ))}
260
+ </ul>
261
+ )}
262
+ </Await>
263
+ </div>
264
+ )
265
+ }
266
+ ```
267
+
268
+ ### Invalidation After Mutations
269
+
270
+ `router.invalidate()` forces all active route loaders to re-run and marks all cached data as stale:
271
+
272
+ ```tsx
273
+ import { useRouter } from '@tanstack/react-router'
274
+
275
+ function AddPostButton() {
276
+ const router = useRouter()
277
+
278
+ const handleAdd = async () => {
279
+ await fetch('/api/posts', { method: 'POST', body: '...' })
280
+ router.invalidate()
281
+ }
282
+
283
+ return <button onClick={handleAdd}>Add Post</button>
284
+ }
285
+ ```
286
+
287
+ For synchronous invalidation (wait until loaders finish):
288
+
289
+ ```tsx
290
+ await router.invalidate({ sync: true })
291
+ ```
292
+
293
+ ### Error Handling
294
+
295
+ ```tsx
296
+ import {
297
+ createFileRoute,
298
+ ErrorComponent,
299
+ useRouter,
300
+ } from '@tanstack/react-router'
301
+
302
+ export const Route = createFileRoute('/posts')({
303
+ loader: () => fetchPosts(),
304
+ errorComponent: ({ error, reset }) => {
305
+ const router = useRouter()
306
+
307
+ if (error instanceof CustomError) {
308
+ return <div>{error.message}</div>
309
+ }
310
+
311
+ return (
312
+ <div>
313
+ <ErrorComponent error={error} />
314
+ <button
315
+ onClick={() => {
316
+ // For loader errors, invalidate to re-run loader + reset boundary
317
+ router.invalidate()
318
+ }}
319
+ >
320
+ Retry
321
+ </button>
322
+ </div>
323
+ )
324
+ },
325
+ })
326
+ ```
327
+
328
+ ### Loader Parameters
329
+
330
+ The `loader` function receives:
331
+
332
+ - `params` — parsed path params
333
+ - `deps` — object from `loaderDeps`
334
+ - `context` — merged parent + beforeLoad context
335
+ - `abortController` — cancelled when route unloads or becomes stale
336
+ - `cause` — `'enter'`, `'stay'`, or `'preload'`
337
+ - `preload` — `true` during preloading
338
+ - `location` — current location object
339
+ - `parentMatchPromise` — promise of parent route match
340
+ - `route` — the route object itself
341
+
342
+ ```tsx
343
+ export const Route = createFileRoute('/posts/$postId')({
344
+ loader: ({ params: { postId }, abortController }) =>
345
+ fetchPost(postId, { signal: abortController.signal }),
346
+ })
347
+ ```
348
+
349
+ ## Common Mistakes
350
+
351
+ ### CRITICAL: Assuming loaders only run on the server
352
+
353
+ TanStack Router is **client-first**. Loaders run on the **client** by default. They also run on the server when using TanStack Start for SSR, but the default mental model is client-side execution.
354
+
355
+ ```tsx
356
+ // WRONG — this will crash in the browser
357
+ export const Route = createFileRoute('/posts')({
358
+ loader: async () => {
359
+ const fs = await import('fs') // Node.js only!
360
+ return JSON.parse(fs.readFileSync('...')) // fails in browser
361
+ },
362
+ })
363
+
364
+ // CORRECT — loaders run in the browser, use fetch or API calls
365
+ export const Route = createFileRoute('/posts')({
366
+ loader: async () => {
367
+ const res = await fetch('/api/posts')
368
+ return res.json()
369
+ },
370
+ })
371
+ ```
372
+
373
+ Do NOT put database queries, filesystem access, or server-only code in loaders unless you are using TanStack Start server functions.
374
+
375
+ ### MEDIUM: Not understanding staleTime default is 0
376
+
377
+ Default `staleTime` is `0`. This means data reloads in the background on every route re-match. This is intentional — it ensures fresh data. But if your data is expensive or static, set `staleTime`:
378
+
379
+ ```tsx
380
+ export const Route = createFileRoute('/posts')({
381
+ loader: () => fetchPosts(),
382
+ staleTime: 60_000, // Consider fresh for 1 minute
383
+ })
384
+ ```
385
+
386
+ ### HIGH: Using reset() instead of router.invalidate() in error components
387
+
388
+ `reset()` only resets the error boundary UI. It does NOT re-run the loader. For loader errors, use `router.invalidate()` which re-runs loaders and resets the boundary:
389
+
390
+ ```tsx
391
+ // WRONG — resets boundary but loader still has stale error
392
+ function PostErrorComponent({ error, reset }) {
393
+ return <button onClick={reset}>Retry</button>
394
+ }
395
+
396
+ // CORRECT — re-runs loader and resets the error boundary
397
+ function PostErrorComponent({ error }) {
398
+ const router = useRouter()
399
+ return <button onClick={() => router.invalidate()}>Retry</button>
400
+ }
401
+ ```
402
+
403
+ ### HIGH: Missing double parentheses on createRootRouteWithContext
404
+
405
+ `createRootRouteWithContext<Type>()` is a factory — it returns a function. Must call twice:
406
+
407
+ ```tsx
408
+ // WRONG — missing second call, passes options to the factory
409
+ const rootRoute = createRootRouteWithContext<{ auth: AuthState }>({
410
+ component: RootComponent,
411
+ })
412
+
413
+ // CORRECT — factory()({options})
414
+ const rootRoute = createRootRouteWithContext<{ auth: AuthState }>()({
415
+ component: RootComponent,
416
+ })
417
+ ```
418
+
419
+ ### HIGH: Using React hooks in beforeLoad or loader
420
+
421
+ `beforeLoad` and `loader` are NOT React components. You cannot call hooks inside them. Use router context to inject values from hooks:
422
+
423
+ ```tsx
424
+ // WRONG — hooks cannot be called outside React components
425
+ export const Route = createFileRoute('/posts')({
426
+ loader: () => {
427
+ const auth = useAuth() // This will crash!
428
+ return fetchPosts(auth.userId)
429
+ },
430
+ })
431
+
432
+ // CORRECT — inject hook values via router context
433
+ // In your App component:
434
+ function InnerApp() {
435
+ const auth = useAuth()
436
+ return <RouterProvider router={router} context={{ auth }} />
437
+ }
438
+
439
+ // In your route:
440
+ export const Route = createFileRoute('/posts')({
441
+ loader: ({ context: { auth } }) => fetchPosts(auth.userId),
442
+ })
443
+ ```
444
+
445
+ ### HIGH: Property order affects TypeScript inference
446
+
447
+ Router infers types from earlier properties into later ones. Declaring `beforeLoad` after `loader` means context from `beforeLoad` is unknown in the loader:
448
+
449
+ ```tsx
450
+ // WRONG — context.user is unknown because beforeLoad declared after loader
451
+ export const Route = createFileRoute('/admin')({
452
+ loader: ({ context }) => fetchData(context.user),
453
+ beforeLoad: () => ({ user: getUser() }),
454
+ })
455
+
456
+ // CORRECT — validateSearch → loaderDeps → beforeLoad → loader
457
+ export const Route = createFileRoute('/admin')({
458
+ beforeLoad: () => ({ user: getUser() }),
459
+ loader: ({ context }) => fetchData(context.user),
460
+ })
461
+ ```
462
+
463
+ ### HIGH: Returning entire search object from loaderDeps
464
+
465
+ ```tsx
466
+ // WRONG — loader re-runs on ANY search param change
467
+ loaderDeps: ({ search }) => search
468
+
469
+ // CORRECT — only re-run when page changes
470
+ loaderDeps: ({ search }) => ({ page: search.page })
471
+ ```
472
+
473
+ Returning the whole `search` object means unrelated param changes (e.g., `sortDirection`, `viewMode`) trigger unnecessary reloads because deep equality fails on the entire object.
474
+
475
+ ## Tensions
476
+
477
+ - **Client-first loaders vs SSR expectations**: Loaders run on the client by default. When using SSR (TanStack Start), they run on both client and server. Browser-only APIs work by default but break under SSR. Server-only APIs (fs, db) break by default but work under Start server functions. See **router-core/ssr/SKILL.md**.
478
+ - **Built-in SWR cache vs external cache coordination**: Router has built-in caching. When using TanStack Query, set `defaultPreloadStaleTime: 0` to avoid double-caching. See **compositions/router-query/SKILL.md**.
479
+
480
+ ---
481
+
482
+ ## Cross-References
483
+
484
+ - See also: **router-core/search-params/SKILL.md** — `loaderDeps` consumes validated search params as cache keys
485
+ - See also: **compositions/router-query/SKILL.md** — for external cache coordination with TanStack Query