first commit
This commit is contained in:
113
.agents/skills/tanstack-router-best-practices/SKILL.md
Normal file
113
.agents/skills/tanstack-router-best-practices/SKILL.md
Normal file
@@ -0,0 +1,113 @@
|
||||
---
|
||||
name: tanstack-router-best-practices
|
||||
description: TanStack Router best practices for type-safe routing, data loading, search params, and navigation. Activate when building React applications with complex routing needs.
|
||||
---
|
||||
|
||||
# TanStack Router Best Practices
|
||||
|
||||
Comprehensive guidelines for implementing TanStack Router patterns in React applications. These rules optimize type safety, data loading, navigation, and code organization.
|
||||
|
||||
## When to Apply
|
||||
|
||||
- Setting up application routing
|
||||
- Creating new routes and layouts
|
||||
- Implementing search parameter handling
|
||||
- Configuring data loaders
|
||||
- Setting up code splitting
|
||||
- Integrating with TanStack Query
|
||||
- Refactoring navigation patterns
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Rules | Impact |
|
||||
|----------|----------|-------|--------|
|
||||
| CRITICAL | Type Safety | 4 rules | Prevents runtime errors and enables refactoring |
|
||||
| CRITICAL | Route Organization | 5 rules | Ensures maintainable route structure |
|
||||
| HIGH | Router Config | 1 rule | Global router defaults |
|
||||
| HIGH | Data Loading | 6 rules | Optimizes data fetching and caching |
|
||||
| HIGH | Search Params | 5 rules | Enables type-safe URL state |
|
||||
| HIGH | Error Handling | 1 rule | Handles 404 and errors gracefully |
|
||||
| MEDIUM | Navigation | 5 rules | Improves UX and accessibility |
|
||||
| MEDIUM | Code Splitting | 3 rules | Reduces bundle size |
|
||||
| MEDIUM | Preloading | 3 rules | Improves perceived performance |
|
||||
| LOW | Route Context | 3 rules | Enables dependency injection |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Type Safety (Prefix: `ts-`)
|
||||
|
||||
- `ts-register-router` — Register router type for global inference
|
||||
- `ts-use-from-param` — Use `from` parameter for type narrowing
|
||||
- `ts-route-context-typing` — Type route context with createRootRouteWithContext
|
||||
- `ts-query-options-loader` — Use queryOptions in loaders for type inference
|
||||
|
||||
### Router Config (Prefix: `router-`)
|
||||
|
||||
- `router-default-options` — Configure router defaults (scrollRestoration, defaultErrorComponent, etc.)
|
||||
|
||||
### Route Organization (Prefix: `org-`)
|
||||
|
||||
- `org-file-based-routing` — Prefer file-based routing for conventions
|
||||
- `org-route-tree-structure` — Follow hierarchical route tree patterns
|
||||
- `org-pathless-layouts` — Use pathless routes for shared layouts
|
||||
- `org-index-routes` — Understand index vs layout routes
|
||||
- `org-virtual-routes` — Understand virtual file routes
|
||||
|
||||
### Data Loading (Prefix: `load-`)
|
||||
|
||||
- `load-use-loaders` — Use route loaders for data fetching
|
||||
- `load-loader-deps` — Define loaderDeps for cache control
|
||||
- `load-ensure-query-data` — Use ensureQueryData with TanStack Query
|
||||
- `load-deferred-data` — Split critical and non-critical data
|
||||
- `load-error-handling` — Handle loader errors appropriately
|
||||
- `load-parallel` — Leverage parallel route loading
|
||||
|
||||
### Search Params (Prefix: `search-`)
|
||||
|
||||
- `search-validation` — Always validate search params
|
||||
- `search-type-inheritance` — Leverage parent search param types
|
||||
- `search-middleware` — Use search param middleware
|
||||
- `search-defaults` — Provide sensible defaults
|
||||
- `search-custom-serializer` — Configure custom search param serializers
|
||||
|
||||
### Error Handling (Prefix: `err-`)
|
||||
|
||||
- `err-not-found` — Handle not-found routes properly
|
||||
|
||||
### Navigation (Prefix: `nav-`)
|
||||
|
||||
- `nav-link-component` — Prefer Link component for navigation
|
||||
- `nav-active-states` — Configure active link states
|
||||
- `nav-use-navigate` — Use useNavigate for programmatic navigation
|
||||
- `nav-relative-paths` — Understand relative path navigation
|
||||
- `nav-route-masks` — Use route masks for modal URLs
|
||||
|
||||
### Code Splitting (Prefix: `split-`)
|
||||
|
||||
- `split-lazy-routes` — Use .lazy.tsx for code splitting
|
||||
- `split-critical-path` — Keep critical config in main route file
|
||||
- `split-auto-splitting` — Enable autoCodeSplitting when possible
|
||||
|
||||
### Preloading (Prefix: `preload-`)
|
||||
|
||||
- `preload-intent` — Enable intent-based preloading
|
||||
- `preload-stale-time` — Configure preload stale time
|
||||
- `preload-manual` — Use manual preloading strategically
|
||||
|
||||
### Route Context (Prefix: `ctx-`)
|
||||
|
||||
- `ctx-root-context` — Define context at root route
|
||||
- `ctx-before-load` — Extend context in beforeLoad
|
||||
- `ctx-dependency-injection` — Use context for dependency injection
|
||||
|
||||
## How to Use
|
||||
|
||||
Each rule file in the `rules/` directory contains:
|
||||
1. **Explanation** — Why this pattern matters
|
||||
2. **Bad Example** — Anti-pattern to avoid
|
||||
3. **Good Example** — Recommended implementation
|
||||
4. **Context** — When to apply or skip this rule
|
||||
|
||||
## Full Reference
|
||||
|
||||
See individual rule files in `rules/` directory for detailed guidance and code examples.
|
||||
@@ -0,0 +1,172 @@
|
||||
# ctx-root-context: Define Context at Root Route
|
||||
|
||||
## Priority: LOW
|
||||
|
||||
## Explanation
|
||||
|
||||
Use `createRootRouteWithContext` to define typed context that flows through your entire route tree. This enables dependency injection for things like query clients, auth state, and services.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// No context - importing globals directly
|
||||
// routes/__root.tsx
|
||||
import { createRootRoute } from '@tanstack/react-router'
|
||||
import { queryClient } from '@/lib/query-client' // Global import
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
// routes/posts.tsx
|
||||
import { queryClient } from '@/lib/query-client' // Import again
|
||||
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async () => {
|
||||
// Using global - harder to test, couples to implementation
|
||||
return queryClient.ensureQueryData(postQueries.list())
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// routes/__root.tsx
|
||||
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
// Define the context interface
|
||||
interface RouterContext {
|
||||
queryClient: QueryClient
|
||||
auth: {
|
||||
user: User | null
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<main>
|
||||
<Outlet />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// router.tsx - Provide context when creating router
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export function getRouter(auth: RouterContext['auth'] = { user: null, isAuthenticated: false }) {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
queryClient,
|
||||
auth,
|
||||
},
|
||||
defaultPreload: 'intent',
|
||||
defaultPreloadStaleTime: 0,
|
||||
scrollRestoration: true,
|
||||
})
|
||||
|
||||
setupRouterSsrQueryIntegration({ router, queryClient })
|
||||
|
||||
return router
|
||||
}
|
||||
|
||||
// routes/posts.tsx - Use context in loaders
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
// Context is typed and injected
|
||||
return queryClient.ensureQueryData(postQueries.list())
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Auth-Protected Routes
|
||||
|
||||
```tsx
|
||||
// routes/__root.tsx
|
||||
interface RouterContext {
|
||||
queryClient: QueryClient
|
||||
auth: AuthState
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
// routes/_authenticated.tsx - Layout route for protected pages
|
||||
export const Route = createFileRoute('/_authenticated')({
|
||||
beforeLoad: async ({ context, location }) => {
|
||||
if (!context.auth.isAuthenticated) {
|
||||
throw redirect({
|
||||
to: '/login',
|
||||
search: { redirect: location.href },
|
||||
})
|
||||
}
|
||||
},
|
||||
component: AuthenticatedLayout,
|
||||
})
|
||||
|
||||
// routes/_authenticated/dashboard.tsx
|
||||
export const Route = createFileRoute('/_authenticated/dashboard')({
|
||||
loader: async ({ context: { queryClient, auth } }) => {
|
||||
// We know user is authenticated from parent beforeLoad
|
||||
return queryClient.ensureQueryData(
|
||||
dashboardQueries.forUser(auth.user!.id)
|
||||
)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Extending Context with beforeLoad
|
||||
|
||||
```tsx
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
beforeLoad: async ({ context, params }) => {
|
||||
// Extend context with route-specific data
|
||||
const post = await fetchPost(params.postId)
|
||||
|
||||
return {
|
||||
post, // Available to this route and children
|
||||
}
|
||||
},
|
||||
loader: async ({ context }) => {
|
||||
// context now includes 'post' from beforeLoad
|
||||
const comments = await fetchComments(context.post.id)
|
||||
return { comments }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Context vs. Loader Data
|
||||
|
||||
| Context | Loader Data |
|
||||
|---------|-------------|
|
||||
| Available in beforeLoad, loader, and component | Only available in component |
|
||||
| Set at router creation or in beforeLoad | Returned from loader |
|
||||
| Good for services, clients, auth | Good for route-specific data |
|
||||
| Flows down to all children | Specific to route |
|
||||
|
||||
## Context
|
||||
|
||||
- Type the context interface in `createRootRouteWithContext<T>()`
|
||||
- Provide context when calling `createRouter({ context: {...} })`
|
||||
- Context flows from root to all nested routes
|
||||
- Use `beforeLoad` to extend context for specific subtrees
|
||||
- Enables testability - inject mocks in tests
|
||||
- Avoids global imports and singletons
|
||||
@@ -0,0 +1,194 @@
|
||||
# err-not-found: Handle Not-Found Routes Properly
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
Configure `notFoundComponent` to handle 404 errors gracefully. TanStack Router provides not-found handling at multiple levels: root, route-specific, and programmatic via `notFound()`. Proper configuration prevents blank screens and improves UX.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// No not-found handling - shows blank screen or error
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
// Missing defaultNotFoundComponent
|
||||
})
|
||||
|
||||
// Or throwing generic error
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await fetchPost(params.postId)
|
||||
if (!post) {
|
||||
throw new Error('Not found') // Generic error, not proper 404
|
||||
}
|
||||
return post
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Root-Level Not Found
|
||||
|
||||
```tsx
|
||||
// routes/__root.tsx
|
||||
export const Route = createRootRoute({
|
||||
component: RootComponent,
|
||||
notFoundComponent: GlobalNotFound,
|
||||
})
|
||||
|
||||
function GlobalNotFound() {
|
||||
return (
|
||||
<div className="not-found">
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<Link to="/">Go Home</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// router.tsx - Can also set default
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultNotFoundComponent: () => (
|
||||
<div>
|
||||
<h1>404</h1>
|
||||
<Link to="/">Return Home</Link>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Route-Specific Not Found
|
||||
|
||||
```tsx
|
||||
// routes/posts/$postId.tsx
|
||||
import { createFileRoute, notFound } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await fetchPost(params.postId)
|
||||
if (!post) {
|
||||
throw notFound() // Proper 404 handling
|
||||
}
|
||||
return post
|
||||
},
|
||||
notFoundComponent: PostNotFound, // Custom 404 for this route
|
||||
component: PostPage,
|
||||
})
|
||||
|
||||
function PostNotFound() {
|
||||
const { postId } = Route.useParams()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Post Not Found</h1>
|
||||
<p>No post exists with ID: {postId}</p>
|
||||
<Link to="/posts">Browse all posts</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Not Found with Data
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/users/$username')({
|
||||
loader: async ({ params }) => {
|
||||
const user = await fetchUser(params.username)
|
||||
if (!user) {
|
||||
throw notFound({
|
||||
// Pass data to notFoundComponent
|
||||
data: {
|
||||
username: params.username,
|
||||
suggestions: await fetchSimilarUsernames(params.username),
|
||||
},
|
||||
})
|
||||
}
|
||||
return user
|
||||
},
|
||||
notFoundComponent: UserNotFound,
|
||||
})
|
||||
|
||||
function UserNotFound() {
|
||||
const { data } = Route.useMatch()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>User @{data?.username} not found</h1>
|
||||
{data?.suggestions?.length > 0 && (
|
||||
<div>
|
||||
<p>Did you mean:</p>
|
||||
<ul>
|
||||
{data.suggestions.map((username) => (
|
||||
<li key={username}>
|
||||
<Link to="/users/$username" params={{ username }}>
|
||||
@{username}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Catch-All Route
|
||||
|
||||
```tsx
|
||||
// routes/$.tsx - Catch-all splat route
|
||||
export const Route = createFileRoute('/$')({
|
||||
component: CatchAllNotFound,
|
||||
})
|
||||
|
||||
function CatchAllNotFound() {
|
||||
const { _splat } = Route.useParams()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Page Not Found</h1>
|
||||
<p>No page exists at: /{_splat}</p>
|
||||
<Link to="/">Go to homepage</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Nested Not Found Bubbling
|
||||
|
||||
```tsx
|
||||
// Not found bubbles up through route tree
|
||||
// routes/posts.tsx
|
||||
export const Route = createFileRoute('/posts')({
|
||||
notFoundComponent: PostsNotFound, // Catches child 404s too
|
||||
})
|
||||
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await fetchPost(params.postId)
|
||||
if (!post) throw notFound()
|
||||
return post
|
||||
},
|
||||
// No notFoundComponent - bubbles to parent
|
||||
})
|
||||
|
||||
// routes/posts/$postId/comments.tsx
|
||||
export const Route = createFileRoute('/posts/$postId/comments')({
|
||||
loader: async ({ params }) => {
|
||||
const comments = await fetchComments(params.postId)
|
||||
if (!comments) throw notFound() // Bubbles to /posts notFoundComponent
|
||||
return comments
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- `notFound()` throws a special error caught by nearest `notFoundComponent`
|
||||
- Not found bubbles up the route tree if not handled locally
|
||||
- Use `defaultNotFoundComponent` on router for global fallback
|
||||
- Pass data to `notFound({ data })` for contextual 404 pages
|
||||
- Catch-all routes (`/$`) can handle truly unknown paths
|
||||
- Different from error boundaries - specifically for 404 cases
|
||||
@@ -0,0 +1,153 @@
|
||||
# load-ensure-query-data: Use ensureQueryData with TanStack Query
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
When integrating TanStack Router with TanStack Query, use `queryClient.ensureQueryData()` in loaders instead of `prefetchQuery()`. This respects the cache, awaits data if missing, and returns the data for potential use.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Using prefetchQuery - doesn't return data, can't await stale check
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// prefetchQuery never throws, swallows errors
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['posts', params.postId],
|
||||
queryFn: () => fetchPost(params.postId),
|
||||
})
|
||||
// No await - might not complete before render
|
||||
// No return value to use
|
||||
},
|
||||
})
|
||||
|
||||
// Fetching directly - bypasses TanStack Query cache
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async () => {
|
||||
const posts = await fetchPosts() // Not cached
|
||||
return { posts }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// Define queryOptions for reuse
|
||||
const postQueryOptions = (postId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['posts', postId],
|
||||
queryFn: () => fetchPost(postId),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// ensureQueryData:
|
||||
// - Returns cached data if fresh
|
||||
// - Fetches and caches if missing or stale
|
||||
// - Awaits completion
|
||||
// - Throws on error (caught by error boundary)
|
||||
await queryClient.ensureQueryData(postQueryOptions(params.postId))
|
||||
},
|
||||
component: PostPage,
|
||||
})
|
||||
|
||||
function PostPage() {
|
||||
const { postId } = Route.useParams()
|
||||
|
||||
// Data guaranteed to exist from loader
|
||||
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
|
||||
|
||||
return <PostContent post={post} />
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Multiple Parallel Queries
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
// Parallel data fetching
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(statsQueries.overview()),
|
||||
queryClient.ensureQueryData(activityQueries.recent()),
|
||||
queryClient.ensureQueryData(notificationQueries.unread()),
|
||||
])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Dependent Queries
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/users/$userId/posts')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// First query needed for second
|
||||
const user = await queryClient.ensureQueryData(
|
||||
userQueries.detail(params.userId)
|
||||
)
|
||||
|
||||
// Dependent query uses result
|
||||
await queryClient.ensureQueryData(
|
||||
postQueries.byAuthor(user.id)
|
||||
)
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Router Configuration for TanStack Query
|
||||
|
||||
```tsx
|
||||
// router.tsx
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute default
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
context: { queryClient },
|
||||
|
||||
// Let TanStack Query manage caching
|
||||
defaultPreloadStaleTime: 0,
|
||||
|
||||
// SSR: Dehydrate query cache
|
||||
dehydrate: () => ({
|
||||
queryClientState: dehydrate(queryClient),
|
||||
}),
|
||||
|
||||
// SSR: Hydrate on client
|
||||
hydrate: (dehydrated) => {
|
||||
hydrate(queryClient, dehydrated.queryClientState)
|
||||
},
|
||||
|
||||
// Wrap with QueryClientProvider
|
||||
Wrap: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
## ensureQueryData vs prefetchQuery vs fetchQuery
|
||||
|
||||
| Method | Returns | Throws | Awaits | Use Case |
|
||||
|--------|---------|--------|--------|----------|
|
||||
| `ensureQueryData` | Data | Yes | Yes | Route loaders (recommended) |
|
||||
| `prefetchQuery` | void | No | Yes | Background prefetching |
|
||||
| `fetchQuery` | Data | Yes | Yes | When you need data immediately |
|
||||
|
||||
## Context
|
||||
|
||||
- `ensureQueryData` is the recommended method for route loaders
|
||||
- Respects `staleTime` - won't refetch fresh cached data
|
||||
- Errors propagate to route error boundaries
|
||||
- Use `queryOptions()` factory for type-safe, reusable query definitions
|
||||
- Set `defaultPreloadStaleTime: 0` to let TanStack Query manage cache
|
||||
- Pair with `useSuspenseQuery` in components for guaranteed data
|
||||
@@ -0,0 +1,190 @@
|
||||
# load-parallel: Leverage Parallel Route Loading
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
TanStack Router loads nested route data in parallel, not sequentially. Structure your routes and loaders to maximize parallelization and avoid creating artificial waterfalls.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Creating waterfall with dependent beforeLoad
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
beforeLoad: async () => {
|
||||
const user = await fetchUser() // 200ms
|
||||
const permissions = await fetchPermissions(user.id) // 200ms
|
||||
const preferences = await fetchPreferences(user.id) // 200ms
|
||||
// Total: 600ms (sequential)
|
||||
|
||||
return { user, permissions, preferences }
|
||||
},
|
||||
})
|
||||
|
||||
// Or nesting data dependencies incorrectly
|
||||
// routes/posts.tsx
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async () => {
|
||||
const posts = await fetchPosts() // 300ms
|
||||
return { posts }
|
||||
},
|
||||
})
|
||||
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
// Waits for parent to complete first - waterfall!
|
||||
const post = await fetchPost(params.postId) // +200ms
|
||||
return { post }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Parallel in Single Loader
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
beforeLoad: async () => {
|
||||
// All requests start simultaneously
|
||||
const [user, config] = await Promise.all([
|
||||
fetchUser(), // 200ms
|
||||
fetchAppConfig(), // 150ms
|
||||
])
|
||||
// Total: 200ms (parallel)
|
||||
|
||||
return { user, config }
|
||||
},
|
||||
loader: async ({ context }) => {
|
||||
// These also run in parallel with each other
|
||||
const [stats, activity, notifications] = await Promise.all([
|
||||
fetchDashboardStats(context.user.id),
|
||||
fetchRecentActivity(context.user.id),
|
||||
fetchNotifications(context.user.id),
|
||||
])
|
||||
|
||||
return { stats, activity, notifications }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Parallel Nested Routes
|
||||
|
||||
```tsx
|
||||
// Parent and child loaders run in PARALLEL
|
||||
// routes/posts.tsx
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async () => {
|
||||
// This runs...
|
||||
const categories = await fetchCategories()
|
||||
return { categories }
|
||||
},
|
||||
})
|
||||
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
// ...at the SAME TIME as this!
|
||||
const post = await fetchPost(params.postId)
|
||||
const comments = await fetchComments(params.postId)
|
||||
return { post, comments }
|
||||
},
|
||||
})
|
||||
|
||||
// Navigation to /posts/123:
|
||||
// - Both loaders start simultaneously
|
||||
// - Total time = max(categoriesTime, postTime + commentsTime)
|
||||
// - NOT categoriesTime + postTime + commentsTime
|
||||
```
|
||||
|
||||
## Good Example: With TanStack Query
|
||||
|
||||
```tsx
|
||||
// routes/posts.tsx
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
// These all start in parallel
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(postQueries.list()),
|
||||
queryClient.ensureQueryData(categoryQueries.all()),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// Runs in parallel with parent loader
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(postQueries.detail(params.postId)),
|
||||
queryClient.ensureQueryData(commentQueries.forPost(params.postId)),
|
||||
])
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: Streaming Non-Critical Data
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// Critical data - await
|
||||
const post = await queryClient.ensureQueryData(
|
||||
postQueries.detail(params.postId)
|
||||
)
|
||||
|
||||
// Non-critical - start but don't await (stream in later)
|
||||
queryClient.prefetchQuery(commentQueries.forPost(params.postId))
|
||||
queryClient.prefetchQuery(relatedQueries.forPost(params.postId))
|
||||
|
||||
return { post }
|
||||
},
|
||||
component: PostPage,
|
||||
})
|
||||
|
||||
function PostPage() {
|
||||
const { post } = Route.useLoaderData()
|
||||
const { postId } = Route.useParams()
|
||||
|
||||
// Critical data ready immediately
|
||||
// Non-critical loads in component with loading state
|
||||
const { data: comments, isLoading } = useQuery(
|
||||
commentQueries.forPost(postId)
|
||||
)
|
||||
|
||||
return (
|
||||
<article>
|
||||
<PostContent post={post} />
|
||||
{isLoading ? <CommentsSkeleton /> : <Comments data={comments} />}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Route Loading Timeline
|
||||
|
||||
```
|
||||
Navigation to /posts/123
|
||||
|
||||
Without parallelization:
|
||||
├─ beforeLoad (parent) ████████
|
||||
├─ loader (parent) ████████
|
||||
├─ beforeLoad (child) ████
|
||||
├─ loader (child) ████████
|
||||
└─ Render █
|
||||
|
||||
With parallelization:
|
||||
├─ beforeLoad (parent) ████████
|
||||
├─ beforeLoad (child) ████
|
||||
├─ loader (parent) ████████
|
||||
├─ loader (child) ████████████
|
||||
└─ Render █
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Nested route loaders run in parallel by default
|
||||
- `beforeLoad` runs before `loader` (for auth, context setup)
|
||||
- Use `Promise.all` for parallel fetches within a single loader
|
||||
- Parent context is available in child loaders (after beforeLoad)
|
||||
- Prefetch non-critical data without awaiting for streaming
|
||||
- Monitor network tab to verify parallelization
|
||||
@@ -0,0 +1,148 @@
|
||||
# load-use-loaders: Use Route Loaders for Data Fetching
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
Route loaders execute before the route renders, enabling data to be ready when the component mounts. This prevents loading waterfalls, enables preloading, and integrates with the router's caching layer.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Fetching in component - creates waterfall
|
||||
function PostsPage() {
|
||||
const [posts, setPosts] = useState<Post[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Route renders, THEN data fetches, THEN UI updates
|
||||
fetchPosts().then((data) => {
|
||||
setPosts(data)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (loading) return <Loading />
|
||||
return <PostList posts={posts} />
|
||||
}
|
||||
|
||||
// No preloading possible - user sees loading state on navigation
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// routes/posts.tsx
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async () => {
|
||||
const posts = await fetchPosts()
|
||||
return { posts }
|
||||
},
|
||||
component: PostsPage,
|
||||
})
|
||||
|
||||
function PostsPage() {
|
||||
// Data is ready when component mounts - no loading state needed
|
||||
const { posts } = Route.useLoaderData()
|
||||
return <PostList posts={posts} />
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: With Parameters
|
||||
|
||||
```tsx
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
// params are type-safe and guaranteed to exist
|
||||
const post = await fetchPost(params.postId)
|
||||
const comments = await fetchComments(params.postId)
|
||||
return { post, comments }
|
||||
},
|
||||
component: PostDetailPage,
|
||||
})
|
||||
|
||||
function PostDetailPage() {
|
||||
const { post, comments } = Route.useLoaderData()
|
||||
const { postId } = Route.useParams()
|
||||
|
||||
return (
|
||||
<article>
|
||||
<h1>{post.title}</h1>
|
||||
<PostContent content={post.content} />
|
||||
<CommentList comments={comments} />
|
||||
</article>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: With TanStack Query
|
||||
|
||||
```tsx
|
||||
// routes/posts/$postId.tsx
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
|
||||
const postQueryOptions = (postId: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['posts', postId],
|
||||
queryFn: () => fetchPost(postId),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// Ensure data is in cache before render
|
||||
await queryClient.ensureQueryData(postQueryOptions(params.postId))
|
||||
},
|
||||
component: PostDetailPage,
|
||||
})
|
||||
|
||||
function PostDetailPage() {
|
||||
const { postId } = Route.useParams()
|
||||
// useSuspenseQuery because loader guarantees data exists
|
||||
const { data: post } = useSuspenseQuery(postQueryOptions(postId))
|
||||
|
||||
return <PostContent post={post} />
|
||||
}
|
||||
```
|
||||
|
||||
## Loader Context Properties
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/posts')({
|
||||
loader: async ({
|
||||
params, // Route path parameters
|
||||
context, // Route context (queryClient, auth, etc.)
|
||||
abortController, // For cancelling stale requests
|
||||
cause, // 'enter' | 'preload' | 'stay'
|
||||
deps, // Dependencies from loaderDeps
|
||||
preload, // Boolean: true if preloading
|
||||
}) => {
|
||||
// Use abortController for fetch cancellation
|
||||
const response = await fetch('/api/posts', {
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
// Different behavior for preload vs navigation
|
||||
if (preload) {
|
||||
// Lighter data for preload
|
||||
return { posts: await response.json() }
|
||||
}
|
||||
|
||||
// Full data for actual navigation
|
||||
const posts = await response.json()
|
||||
const stats = await fetchStats()
|
||||
return { posts, stats }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Loaders run during route matching, before component render
|
||||
- Supports parallel loading across nested routes
|
||||
- Enables preloading on link hover/focus
|
||||
- Built-in stale-while-revalidate caching
|
||||
- For complex caching needs, integrate with TanStack Query
|
||||
- Use `beforeLoad` for auth checks and redirects
|
||||
@@ -0,0 +1,178 @@
|
||||
# nav-link-component: Prefer Link Component for Navigation
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Use the `<Link>` component for navigation instead of `useNavigate()` when possible. Links render proper `<a>` tags with valid `href` attributes, enabling right-click → open in new tab, better SEO, and accessibility.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Using onClick with navigate - loses standard link behavior
|
||||
function PostCard({ post }: { post: Post }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate({ to: '/posts/$postId', params: { postId: post.id } })}
|
||||
className="post-card"
|
||||
>
|
||||
<h2>{post.title}</h2>
|
||||
<p>{post.excerpt}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// Problems:
|
||||
// - No right-click → open in new tab
|
||||
// - No cmd/ctrl+click for new tab
|
||||
// - Not announced as link to screen readers
|
||||
// - No valid href for SEO
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
function PostCard({ post }: { post: Post }) {
|
||||
return (
|
||||
<Link
|
||||
to="/posts/$postId"
|
||||
params={{ postId: post.id }}
|
||||
className="post-card"
|
||||
>
|
||||
<h2>{post.title}</h2>
|
||||
<p>{post.excerpt}</p>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
// Benefits:
|
||||
// - Renders <a href="/posts/123">
|
||||
// - Right-click menu works
|
||||
// - Cmd/Ctrl+click opens new tab
|
||||
// - Screen readers announce as link
|
||||
// - Preloading works on hover
|
||||
```
|
||||
|
||||
## Good Example: With Search Params
|
||||
|
||||
```tsx
|
||||
function FilteredLink() {
|
||||
return (
|
||||
<Link
|
||||
to="/products"
|
||||
search={{ category: 'electronics', sort: 'price' }}
|
||||
>
|
||||
View Electronics
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Preserving existing search params
|
||||
function SortLink({ sort }: { sort: 'asc' | 'desc' }) {
|
||||
return (
|
||||
<Link
|
||||
to="." // Current route
|
||||
search={(prev) => ({ ...prev, sort })}
|
||||
>
|
||||
Sort {sort === 'asc' ? 'Ascending' : 'Descending'}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: With Active States
|
||||
|
||||
```tsx
|
||||
function NavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
activeProps={{
|
||||
className: 'nav-link-active',
|
||||
'aria-current': 'page',
|
||||
}}
|
||||
inactiveProps={{
|
||||
className: 'nav-link',
|
||||
}}
|
||||
activeOptions={{
|
||||
exact: true, // Only active on exact match
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// Or use render props for more control
|
||||
function CustomNavLink({ to, children }: { to: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Link to={to}>
|
||||
{({ isActive }) => (
|
||||
<span className={isActive ? 'text-blue-600 font-bold' : 'text-gray-600'}>
|
||||
{children}
|
||||
{isActive && <CheckIcon className="ml-2" />}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: With Preloading
|
||||
|
||||
```tsx
|
||||
function PostList({ posts }: { posts: Post[] }) {
|
||||
return (
|
||||
<ul>
|
||||
{posts.map(post => (
|
||||
<li key={post.id}>
|
||||
<Link
|
||||
to="/posts/$postId"
|
||||
params={{ postId: post.id }}
|
||||
preload="intent" // Preload on hover/focus
|
||||
preloadDelay={100} // Wait 100ms before preloading
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## When to Use useNavigate Instead
|
||||
|
||||
```tsx
|
||||
// 1. After form submission
|
||||
const createPost = useMutation({
|
||||
mutationFn: submitPost,
|
||||
onSuccess: (data) => {
|
||||
navigate({ to: '/posts/$postId', params: { postId: data.id } })
|
||||
},
|
||||
})
|
||||
|
||||
// 2. After authentication
|
||||
async function handleLogin(credentials: Credentials) {
|
||||
await login(credentials)
|
||||
navigate({ to: '/dashboard' })
|
||||
}
|
||||
|
||||
// 3. Programmatic redirects
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
navigate({ to: '/login', search: { redirect: location.pathname } })
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- `<Link>` renders actual `<a>` tags with proper `href`
|
||||
- Supports all standard link behaviors (middle-click, cmd+click, etc.)
|
||||
- Enables preloading on hover/focus
|
||||
- Better for SEO - crawlers can follow links
|
||||
- Reserve `useNavigate` for side effects and programmatic navigation
|
||||
- Use `<Navigate>` component for immediate redirects on render
|
||||
@@ -0,0 +1,197 @@
|
||||
# nav-route-masks: Use Route Masks for Modal URLs
|
||||
|
||||
## Priority: LOW
|
||||
|
||||
## Explanation
|
||||
|
||||
Route masks let you display one URL while internally routing to another. This is useful for modals, sheets, and overlays where you want a shareable URL that shows the modal, but navigating there directly should show the full page.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Modal without proper URL handling
|
||||
function PostList() {
|
||||
const [selectedPost, setSelectedPost] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{posts.map(post => (
|
||||
<div key={post.id} onClick={() => setSelectedPost(post.id)}>
|
||||
{post.title}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedPost && (
|
||||
<Modal onClose={() => setSelectedPost(null)}>
|
||||
<PostDetail postId={selectedPost} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Problems:
|
||||
// - URL doesn't change when modal opens
|
||||
// - Can't share link to modal
|
||||
// - Back button doesn't close modal
|
||||
// - Refresh loses modal state
|
||||
```
|
||||
|
||||
## Good Example: Route Masks for Modal
|
||||
|
||||
```tsx
|
||||
// routes/posts.tsx
|
||||
export const Route = createFileRoute('/posts')({
|
||||
component: PostList,
|
||||
})
|
||||
|
||||
function PostList() {
|
||||
const posts = usePosts()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{posts.map(post => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to="/posts/$postId"
|
||||
params={{ postId: post.id }}
|
||||
mask={{
|
||||
to: '/posts',
|
||||
// URL shows /posts but routes to /posts/$postId
|
||||
}}
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
))}
|
||||
<Outlet /> {/* Modal renders here */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// routes/posts/$postId.tsx
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
component: PostModal,
|
||||
})
|
||||
|
||||
function PostModal() {
|
||||
const { postId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return (
|
||||
<Modal onClose={() => navigate({ to: '/posts' })}>
|
||||
<PostDetail postId={postId} />
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
// User clicks post:
|
||||
// - URL stays /posts (masked)
|
||||
// - PostModal renders
|
||||
// - Share link goes to /posts/$postId (real URL)
|
||||
// - Direct navigation to /posts/$postId shows full page (no mask)
|
||||
```
|
||||
|
||||
## Good Example: With Search Params
|
||||
|
||||
```tsx
|
||||
function PostList() {
|
||||
return (
|
||||
<div>
|
||||
{posts.map(post => (
|
||||
<Link
|
||||
key={post.id}
|
||||
to="/posts/$postId"
|
||||
params={{ postId: post.id }}
|
||||
mask={{
|
||||
to: '/posts',
|
||||
search: { modal: post.id }, // /posts?modal=123
|
||||
}}
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Programmatic Navigation with Mask
|
||||
|
||||
```tsx
|
||||
function PostCard({ post }: { post: Post }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const openInModal = () => {
|
||||
navigate({
|
||||
to: '/posts/$postId',
|
||||
params: { postId: post.id },
|
||||
mask: {
|
||||
to: '/posts',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openFullPage = () => {
|
||||
navigate({
|
||||
to: '/posts/$postId',
|
||||
params: { postId: post.id },
|
||||
// No mask - shows real URL
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>{post.title}</h3>
|
||||
<button onClick={openInModal}>Quick View</button>
|
||||
<button onClick={openFullPage}>Full Page</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Unmask on Interaction
|
||||
|
||||
```tsx
|
||||
function PostModal() {
|
||||
const { postId } = Route.useParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const expandToFullPage = () => {
|
||||
// Navigate to real URL, removing mask
|
||||
navigate({
|
||||
to: '/posts/$postId',
|
||||
params: { postId },
|
||||
// No mask = real URL
|
||||
replace: true, // Replace history entry
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal>
|
||||
<PostDetail postId={postId} />
|
||||
<button onClick={expandToFullPage}>
|
||||
Expand to full page
|
||||
</button>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Route Mask Behavior
|
||||
|
||||
| Scenario | URL Shown | Actual Route |
|
||||
|----------|-----------|--------------|
|
||||
| Click masked link | Masked URL | Real route |
|
||||
| Share/copy URL | Real URL | Real route |
|
||||
| Direct navigation | Real URL | Real route |
|
||||
| Browser refresh | Depends on URL in bar | Matches URL |
|
||||
| Back button | Previous URL | Previous route |
|
||||
|
||||
## Context
|
||||
|
||||
- Masks are client-side only - shared URLs are the real route
|
||||
- Direct navigation to real URL bypasses mask (shows full page)
|
||||
- Back button navigates through history correctly
|
||||
- Use for modals, side panels, quick views
|
||||
- Masks can include different search params
|
||||
- Consider UX: users expect shared URLs to work
|
||||
@@ -0,0 +1,133 @@
|
||||
# org-virtual-routes: Understand Virtual File Routes
|
||||
|
||||
## Priority: LOW
|
||||
|
||||
## Explanation
|
||||
|
||||
Virtual routes are automatically generated placeholder routes in the route tree when you have a `.lazy.tsx` file without a corresponding main route file. They provide the minimal configuration needed to anchor lazy-loaded components.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Creating unnecessary boilerplate main route files
|
||||
// routes/settings.tsx - Just to have a file
|
||||
export const Route = createFileRoute('/settings')({
|
||||
// Empty - no loader, no beforeLoad, nothing
|
||||
})
|
||||
|
||||
// routes/settings.lazy.tsx - Actual component
|
||||
export const Route = createLazyFileRoute('/settings')({
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
// The main file is unnecessary boilerplate
|
||||
```
|
||||
|
||||
## Good Example: Let Virtual Routes Handle It
|
||||
|
||||
```tsx
|
||||
// Delete routes/settings.tsx entirely!
|
||||
|
||||
// routes/settings.lazy.tsx - Only file needed
|
||||
export const Route = createLazyFileRoute('/settings')({
|
||||
component: SettingsPage,
|
||||
pendingComponent: SettingsLoading,
|
||||
errorComponent: SettingsError,
|
||||
})
|
||||
|
||||
function SettingsPage() {
|
||||
return <div>Settings Content</div>
|
||||
}
|
||||
|
||||
// TanStack Router auto-generates a virtual route:
|
||||
// {
|
||||
// path: '/settings',
|
||||
// // Minimal config to anchor the lazy file
|
||||
// }
|
||||
```
|
||||
|
||||
## Good Example: When You DO Need Main Route File
|
||||
|
||||
```tsx
|
||||
// routes/dashboard.tsx - Need this for loader/beforeLoad
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
beforeLoad: async ({ context }) => {
|
||||
if (!context.auth.isAuthenticated) {
|
||||
throw redirect({ to: '/login' })
|
||||
}
|
||||
},
|
||||
loader: async ({ context: { queryClient } }) => {
|
||||
await queryClient.ensureQueryData(dashboardQueries.stats())
|
||||
},
|
||||
// Component is in lazy file
|
||||
})
|
||||
|
||||
// routes/dashboard.lazy.tsx
|
||||
export const Route = createLazyFileRoute('/dashboard')({
|
||||
component: DashboardPage,
|
||||
pendingComponent: DashboardSkeleton,
|
||||
})
|
||||
|
||||
// Main file IS needed here because we have loader/beforeLoad
|
||||
```
|
||||
|
||||
## Decision Guide
|
||||
|
||||
| Route Has... | Need Main File? | Use Virtual? |
|
||||
|--------------|-----------------|--------------|
|
||||
| Only component | No | Yes |
|
||||
| loader | Yes | No |
|
||||
| beforeLoad | Yes | No |
|
||||
| validateSearch | Yes | No |
|
||||
| loaderDeps | Yes | No |
|
||||
| Just pendingComponent/errorComponent | No | Yes |
|
||||
|
||||
## Good Example: File Structure with Virtual Routes
|
||||
|
||||
```
|
||||
routes/
|
||||
├── __root.tsx # Always needed
|
||||
├── index.tsx # Has loader
|
||||
├── about.lazy.tsx # Virtual route (no main file)
|
||||
├── contact.lazy.tsx # Virtual route (no main file)
|
||||
├── dashboard.tsx # Has beforeLoad (auth)
|
||||
├── dashboard.lazy.tsx # Component
|
||||
├── posts.tsx # Has loader
|
||||
├── posts.lazy.tsx # Component
|
||||
├── posts/
|
||||
│ ├── $postId.tsx # Has loader
|
||||
│ └── $postId.lazy.tsx # Component
|
||||
└── settings/
|
||||
├── index.lazy.tsx # Virtual route
|
||||
├── profile.lazy.tsx # Virtual route
|
||||
└── security.tsx # Has beforeLoad (requires re-auth)
|
||||
```
|
||||
|
||||
## Good Example: Generated Route Tree
|
||||
|
||||
```tsx
|
||||
// routeTree.gen.ts (auto-generated)
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as aboutLazyRoute } from './routes/about.lazy' // Virtual parent
|
||||
|
||||
export const routeTree = rootRoute.addChildren([
|
||||
// Virtual route created for about.lazy.tsx
|
||||
createRoute({
|
||||
path: '/about',
|
||||
getParentRoute: () => rootRoute,
|
||||
}).lazy(() => import('./routes/about.lazy').then(m => m.Route)),
|
||||
|
||||
// Regular route with explicit main file
|
||||
dashboardRoute.addChildren([...]),
|
||||
])
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Virtual routes reduce boilerplate for simple pages
|
||||
- Only works with file-based routing
|
||||
- Auto-generated in `routeTree.gen.ts`
|
||||
- Main route file needed for any "critical path" config
|
||||
- Critical: loader, beforeLoad, validateSearch, loaderDeps, context
|
||||
- Non-critical (can be in lazy): component, pendingComponent, errorComponent
|
||||
- Check generated route tree to verify virtual routes
|
||||
@@ -0,0 +1,133 @@
|
||||
# preload-intent: Enable Intent-Based Preloading
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Configure `defaultPreload: 'intent'` to preload routes when users hover or focus links. This loads data before the click, making navigation feel instant.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// No preloading configured - data loads after click
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
// No defaultPreload - user waits after every navigation
|
||||
})
|
||||
|
||||
// Each navigation shows loading state
|
||||
function PostList({ posts }: { posts: Post[] }) {
|
||||
return (
|
||||
<ul>
|
||||
{posts.map(post => (
|
||||
<li key={post.id}>
|
||||
<Link to="/posts/$postId" params={{ postId: post.id }}>
|
||||
{post.title}
|
||||
</Link>
|
||||
{/* Click → wait for data → render */}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// router.tsx - Enable preloading by default
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent', // Preload on hover/focus
|
||||
defaultPreloadDelay: 50, // Wait 50ms before starting
|
||||
})
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
// Links automatically preload on hover
|
||||
function PostList({ posts }: { posts: Post[] }) {
|
||||
return (
|
||||
<ul>
|
||||
{posts.map(post => (
|
||||
<li key={post.id}>
|
||||
<Link to="/posts/$postId" params={{ postId: post.id }}>
|
||||
{post.title}
|
||||
</Link>
|
||||
{/* Hover → preload starts → click → instant navigation */}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Preload Options
|
||||
|
||||
```tsx
|
||||
// Router-level defaults
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent', // 'intent' | 'render' | 'viewport' | false
|
||||
defaultPreloadDelay: 50, // ms before preload starts
|
||||
defaultPreloadStaleTime: 30000, // 30s - how long preloaded data stays fresh
|
||||
})
|
||||
|
||||
// Link-level overrides
|
||||
<Link
|
||||
to="/heavy-page"
|
||||
preload={false} // Disable for this specific link
|
||||
>
|
||||
Heavy Page
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/critical-page"
|
||||
preload="render" // Preload immediately when Link renders
|
||||
>
|
||||
Critical Page
|
||||
</Link>
|
||||
```
|
||||
|
||||
## Preload Strategies
|
||||
|
||||
| Strategy | Behavior | Use Case |
|
||||
|----------|----------|----------|
|
||||
| `'intent'` | Preload on hover/focus | Default for most links |
|
||||
| `'render'` | Preload when Link mounts | Critical next pages |
|
||||
| `'viewport'` | Preload when Link enters viewport | Below-fold content |
|
||||
| `false` | No preloading | Heavy, rarely-visited pages |
|
||||
|
||||
## Good Example: With TanStack Query Integration
|
||||
|
||||
```tsx
|
||||
// When using TanStack Query, disable router cache
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
defaultPreloadStaleTime: 0, // Let TanStack Query manage cache
|
||||
context: {
|
||||
queryClient,
|
||||
},
|
||||
})
|
||||
|
||||
// Route loader uses TanStack Query
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params, context: { queryClient } }) => {
|
||||
// ensureQueryData respects TanStack Query's staleTime
|
||||
await queryClient.ensureQueryData(postQueries.detail(params.postId))
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Preloading loads route code AND executes loaders
|
||||
- `preloadDelay` prevents excessive requests on quick mouse movements
|
||||
- Preloaded data is garbage collected after `preloadStaleTime`
|
||||
- Works with both router caching and external caching (TanStack Query)
|
||||
- Mobile: Consider `'viewport'` since hover isn't available
|
||||
- Monitor network tab to verify preloading works correctly
|
||||
@@ -0,0 +1,163 @@
|
||||
# router-default-options: Configure Router Default Options
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
TanStack Router's `createRouter` accepts several default options that apply globally. Configure these for consistent behavior across your application including error handling, scroll restoration, and performance optimizations.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Minimal router - missing useful defaults
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: { queryClient },
|
||||
})
|
||||
|
||||
// Each route must handle its own errors
|
||||
// No scroll restoration on navigation
|
||||
// No preloading configured
|
||||
```
|
||||
|
||||
## Good Example: Full Configuration
|
||||
|
||||
```tsx
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
import { DefaultCatchBoundary } from '@/components/DefaultCatchBoundary'
|
||||
import { DefaultNotFound } from '@/components/DefaultNotFound'
|
||||
|
||||
export function getRouter() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1000 * 60 * 2,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: { queryClient, user: null },
|
||||
|
||||
// Preloading
|
||||
defaultPreload: 'intent', // Preload on hover/focus
|
||||
defaultPreloadStaleTime: 0, // Let Query manage freshness
|
||||
|
||||
// Error handling
|
||||
defaultErrorComponent: DefaultCatchBoundary,
|
||||
defaultNotFoundComponent: DefaultNotFound,
|
||||
|
||||
// UX
|
||||
scrollRestoration: true, // Restore scroll on back/forward
|
||||
|
||||
// Performance
|
||||
defaultStructuralSharing: true, // Optimize re-renders
|
||||
})
|
||||
|
||||
setupRouterSsrQueryIntegration({
|
||||
router,
|
||||
queryClient,
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: DefaultCatchBoundary Component
|
||||
|
||||
```tsx
|
||||
// components/DefaultCatchBoundary.tsx
|
||||
import { ErrorComponent, useRouter } from '@tanstack/react-router'
|
||||
|
||||
export function DefaultCatchBoundary({ error }: { error: Error }) {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="error-container">
|
||||
<h1>Something went wrong</h1>
|
||||
<ErrorComponent error={error} />
|
||||
<button onClick={() => router.invalidate()}>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: DefaultNotFound Component
|
||||
|
||||
```tsx
|
||||
// components/DefaultNotFound.tsx
|
||||
import { Link } from '@tanstack/react-router'
|
||||
|
||||
export function DefaultNotFound() {
|
||||
return (
|
||||
<div className="not-found-container">
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<Link to="/">Go home</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Router Options Reference
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `defaultPreload` | `false \| 'intent' \| 'render' \| 'viewport'` | `false` | When to preload routes |
|
||||
| `defaultPreloadStaleTime` | `number` | `30000` | How long preloaded data stays fresh (ms) |
|
||||
| `defaultErrorComponent` | `Component` | Built-in | Global error boundary |
|
||||
| `defaultNotFoundComponent` | `Component` | Built-in | Global 404 page |
|
||||
| `scrollRestoration` | `boolean` | `false` | Restore scroll on navigation |
|
||||
| `defaultStructuralSharing` | `boolean` | `true` | Optimize loader data re-renders |
|
||||
|
||||
## Good Example: Route-Level Overrides
|
||||
|
||||
```tsx
|
||||
// Routes can override defaults
|
||||
export const Route = createFileRoute('/admin')({
|
||||
// Custom error handling for admin section
|
||||
errorComponent: AdminErrorBoundary,
|
||||
notFoundComponent: AdminNotFound,
|
||||
|
||||
// Disable preload for sensitive routes
|
||||
preload: false,
|
||||
})
|
||||
```
|
||||
|
||||
## Good Example: With Pending Component
|
||||
|
||||
```tsx
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: { queryClient },
|
||||
|
||||
defaultPreload: 'intent',
|
||||
defaultPreloadStaleTime: 0,
|
||||
defaultErrorComponent: DefaultCatchBoundary,
|
||||
defaultNotFoundComponent: DefaultNotFound,
|
||||
scrollRestoration: true,
|
||||
|
||||
// Show during route transitions
|
||||
defaultPendingComponent: () => (
|
||||
<div className="loading-bar" />
|
||||
),
|
||||
defaultPendingMinMs: 200, // Min time to show pending UI
|
||||
defaultPendingMs: 1000, // Delay before showing pending UI
|
||||
})
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Set `defaultPreloadStaleTime: 0` when using TanStack Query
|
||||
- `scrollRestoration: true` improves back/forward navigation UX
|
||||
- `defaultStructuralSharing` prevents unnecessary re-renders
|
||||
- Route-level options override router defaults
|
||||
- Error/NotFound components receive route context
|
||||
- Pending components help with perceived performance
|
||||
@@ -0,0 +1,198 @@
|
||||
# search-custom-serializer: Configure Custom Search Param Serializers
|
||||
|
||||
## Priority: LOW
|
||||
|
||||
## Explanation
|
||||
|
||||
By default, TanStack Router serializes search params as JSON. For cleaner URLs or compatibility with external systems, you can provide custom serializers using libraries like `qs`, `query-string`, or your own implementation.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Default JSON serialization creates ugly URLs
|
||||
// URL: /products?filters=%7B%22category%22%3A%22electronics%22%2C%22inStock%22%3Atrue%7D
|
||||
|
||||
// Or manually parsing/serializing inconsistently
|
||||
function ProductList() {
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const filters = JSON.parse(searchParams.get('filters') || '{}')
|
||||
// Inconsistent with router's handling
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Using JSURL for Compact URLs
|
||||
|
||||
```tsx
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import JSURL from 'jsurl2'
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
search: {
|
||||
// Custom serializer for compact, URL-safe encoding
|
||||
serialize: (search) => JSURL.stringify(search),
|
||||
parse: (searchString) => JSURL.parse(searchString) || {},
|
||||
},
|
||||
})
|
||||
|
||||
// URL: /products?~(category~'electronics~inStock~true)
|
||||
// Much shorter than JSON!
|
||||
```
|
||||
|
||||
## Good Example: Using query-string for Flat Params
|
||||
|
||||
```tsx
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import queryString from 'query-string'
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
search: {
|
||||
serialize: (search) =>
|
||||
queryString.stringify(search, {
|
||||
arrayFormat: 'bracket',
|
||||
skipNull: true,
|
||||
}),
|
||||
parse: (searchString) =>
|
||||
queryString.parse(searchString, {
|
||||
arrayFormat: 'bracket',
|
||||
parseBooleans: true,
|
||||
parseNumbers: true,
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
// URL: /products?category=electronics&inStock=true&tags[]=sale&tags[]=new
|
||||
// Traditional query string format
|
||||
```
|
||||
|
||||
## Good Example: Using qs for Nested Objects
|
||||
|
||||
```tsx
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import qs from 'qs'
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
search: {
|
||||
serialize: (search) =>
|
||||
qs.stringify(search, {
|
||||
encodeValuesOnly: true,
|
||||
arrayFormat: 'brackets',
|
||||
}),
|
||||
parse: (searchString) =>
|
||||
qs.parse(searchString, {
|
||||
ignoreQueryPrefix: true,
|
||||
decoder(value) {
|
||||
// Parse booleans and numbers
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
if (/^-?\d+$/.test(value)) return parseInt(value, 10)
|
||||
return value
|
||||
},
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
// URL: /products?filters[category]=electronics&filters[price][min]=100&filters[price][max]=500
|
||||
```
|
||||
|
||||
## Good Example: Base64 for Complex State
|
||||
|
||||
```tsx
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
search: {
|
||||
serialize: (search) => {
|
||||
if (Object.keys(search).length === 0) return ''
|
||||
const json = JSON.stringify(search)
|
||||
return btoa(json) // Base64 encode
|
||||
},
|
||||
parse: (searchString) => {
|
||||
if (!searchString) return {}
|
||||
try {
|
||||
return JSON.parse(atob(searchString)) // Base64 decode
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// URL: /products?eyJjYXRlZ29yeSI6ImVsZWN0cm9uaWNzIn0
|
||||
// Opaque but compact
|
||||
```
|
||||
|
||||
## Good Example: Hybrid Approach
|
||||
|
||||
```tsx
|
||||
// Some params as regular query, complex ones as JSON
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
search: {
|
||||
serialize: (search) => {
|
||||
const { filters, ...simple } = search
|
||||
const params = new URLSearchParams()
|
||||
|
||||
// Simple values as regular params
|
||||
Object.entries(simple).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
params.set(key, String(value))
|
||||
}
|
||||
})
|
||||
|
||||
// Complex filters as JSON
|
||||
if (filters && Object.keys(filters).length > 0) {
|
||||
params.set('filters', JSON.stringify(filters))
|
||||
}
|
||||
|
||||
return params.toString()
|
||||
},
|
||||
parse: (searchString) => {
|
||||
const params = new URLSearchParams(searchString)
|
||||
const result: Record<string, unknown> = {}
|
||||
|
||||
params.forEach((value, key) => {
|
||||
if (key === 'filters') {
|
||||
result.filters = JSON.parse(value)
|
||||
} else if (value === 'true') {
|
||||
result[key] = true
|
||||
} else if (value === 'false') {
|
||||
result[key] = false
|
||||
} else if (/^-?\d+$/.test(value)) {
|
||||
result[key] = parseInt(value, 10)
|
||||
} else {
|
||||
result[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// URL: /products?page=1&sort=price&filters={"category":"electronics","inStock":true}
|
||||
```
|
||||
|
||||
## Serializer Comparison
|
||||
|
||||
| Library | URL Style | Best For |
|
||||
|---------|-----------|----------|
|
||||
| Default (JSON) | `?data=%7B...%7D` | TypeScript safety |
|
||||
| jsurl2 | `?~(key~'value)` | Compact, readable |
|
||||
| query-string | `?key=value&arr[]=1` | Traditional APIs |
|
||||
| qs | `?obj[nested]=value` | Deep nesting |
|
||||
| Base64 | `?eyJrZXkiOiJ2YWx1ZSJ9` | Opaque, compact |
|
||||
|
||||
## Context
|
||||
|
||||
- Custom serializers apply globally to all routes
|
||||
- Route-level `validateSearch` still works after parsing
|
||||
- Consider URL length limits (~2000 chars for safe cross-browser)
|
||||
- SEO: Search engines may not understand custom formats
|
||||
- Bookmarkability: Users can't easily modify opaque URLs
|
||||
- Debugging: JSON is easier to read in browser devtools
|
||||
@@ -0,0 +1,158 @@
|
||||
# search-validation: Always Validate Search Params
|
||||
|
||||
## Priority: HIGH
|
||||
|
||||
## Explanation
|
||||
|
||||
Search params come from the URL - user-controlled input that must be validated. Use `validateSearch` to parse, validate, and provide defaults. This ensures type safety and prevents runtime errors from malformed URLs.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// No validation - trusting URL input directly
|
||||
export const Route = createFileRoute('/products')({
|
||||
component: ProductsPage,
|
||||
})
|
||||
|
||||
function ProductsPage() {
|
||||
// Accessing raw search params without validation
|
||||
const searchParams = new URLSearchParams(window.location.search)
|
||||
const page = parseInt(searchParams.get('page') || '1') // Could be NaN
|
||||
const sort = searchParams.get('sort') as 'asc' | 'desc' // Could be anything
|
||||
|
||||
// Runtime errors possible if URL is malformed
|
||||
return <ProductList page={page} sort={sort} />
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: Manual Validation
|
||||
|
||||
```tsx
|
||||
export const Route = createFileRoute('/products')({
|
||||
validateSearch: (search: Record<string, unknown>) => {
|
||||
return {
|
||||
page: Number(search.page) || 1,
|
||||
sort: search.sort === 'desc' ? 'desc' : 'asc',
|
||||
category: typeof search.category === 'string' ? search.category : undefined,
|
||||
minPrice: Number(search.minPrice) || undefined,
|
||||
maxPrice: Number(search.maxPrice) || undefined,
|
||||
}
|
||||
},
|
||||
component: ProductsPage,
|
||||
})
|
||||
|
||||
function ProductsPage() {
|
||||
// Fully typed, validated search params
|
||||
const { page, sort, category, minPrice, maxPrice } = Route.useSearch()
|
||||
// page: number (default 1)
|
||||
// sort: 'asc' | 'desc' (default 'asc')
|
||||
// category: string | undefined
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: With Zod
|
||||
|
||||
```tsx
|
||||
import { z } from 'zod'
|
||||
|
||||
const productSearchSchema = z.object({
|
||||
page: z.number().min(1).catch(1),
|
||||
limit: z.number().min(1).max(100).catch(20),
|
||||
sort: z.enum(['name', 'price', 'date']).catch('name'),
|
||||
order: z.enum(['asc', 'desc']).catch('asc'),
|
||||
category: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
minPrice: z.number().min(0).optional(),
|
||||
maxPrice: z.number().min(0).optional(),
|
||||
})
|
||||
|
||||
type ProductSearch = z.infer<typeof productSearchSchema>
|
||||
|
||||
export const Route = createFileRoute('/products')({
|
||||
validateSearch: (search) => productSearchSchema.parse(search),
|
||||
component: ProductsPage,
|
||||
})
|
||||
|
||||
function ProductsPage() {
|
||||
const search = Route.useSearch()
|
||||
// search: ProductSearch - fully typed with defaults
|
||||
|
||||
return (
|
||||
<ProductList
|
||||
page={search.page}
|
||||
limit={search.limit}
|
||||
sort={search.sort}
|
||||
order={search.order}
|
||||
filters={{
|
||||
category: search.category,
|
||||
search: search.search,
|
||||
priceRange: search.minPrice && search.maxPrice
|
||||
? [search.minPrice, search.maxPrice]
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example: With Valibot
|
||||
|
||||
```tsx
|
||||
import * as v from 'valibot'
|
||||
import { valibotSearchValidator } from '@tanstack/router-valibot-adapter'
|
||||
|
||||
const searchSchema = v.object({
|
||||
page: v.fallback(v.number(), 1),
|
||||
query: v.fallback(v.string(), ''),
|
||||
filters: v.fallback(
|
||||
v.array(v.string()),
|
||||
[]
|
||||
),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/search')({
|
||||
validateSearch: valibotSearchValidator(searchSchema),
|
||||
component: SearchPage,
|
||||
})
|
||||
```
|
||||
|
||||
## Updating Search Params
|
||||
|
||||
```tsx
|
||||
function ProductFilters() {
|
||||
const navigate = useNavigate()
|
||||
const search = Route.useSearch()
|
||||
|
||||
const updateFilters = (newFilters: Partial<ProductSearch>) => {
|
||||
navigate({
|
||||
to: '.', // Current route
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
...newFilters,
|
||||
page: 1, // Reset to page 1 when filters change
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<select
|
||||
value={search.sort}
|
||||
onChange={(e) => updateFilters({ sort: e.target.value as ProductSearch['sort'] })}
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="price">Price</option>
|
||||
<option value="date">Date</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Search params are user input - never trust them unvalidated
|
||||
- Use `.catch()` in Zod or `fallback()` in Valibot for graceful defaults
|
||||
- Validation runs on every navigation - keep it fast
|
||||
- Search params are inherited by child routes
|
||||
- Use `search` updater function to preserve other params
|
||||
@@ -0,0 +1,148 @@
|
||||
# split-lazy-routes: Use .lazy.tsx for Code Splitting
|
||||
|
||||
## Priority: MEDIUM
|
||||
|
||||
## Explanation
|
||||
|
||||
Split route components into `.lazy.tsx` files to reduce initial bundle size. The main route file keeps critical configuration (path, loaders, search validation), while lazy files contain components that load on-demand.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// routes/dashboard.tsx - Everything in one file
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { HeavyChartLibrary } from 'heavy-chart-library'
|
||||
import { ComplexDataGrid } from 'complex-data-grid'
|
||||
import { AnalyticsWidgets } from './components/AnalyticsWidgets'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async ({ context }) => {
|
||||
return context.queryClient.ensureQueryData(dashboardQueries.stats())
|
||||
},
|
||||
component: DashboardPage, // Entire component in main bundle
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
// Heavy components loaded even if user never visits dashboard
|
||||
return (
|
||||
<div>
|
||||
<HeavyChartLibrary data={useLoaderData()} />
|
||||
<ComplexDataGrid />
|
||||
<AnalyticsWidgets />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// routes/dashboard.tsx - Only critical config
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/dashboard')({
|
||||
loader: async ({ context }) => {
|
||||
return context.queryClient.ensureQueryData(dashboardQueries.stats())
|
||||
},
|
||||
// No component - it's in the lazy file
|
||||
})
|
||||
|
||||
// routes/dashboard.lazy.tsx - Lazy-loaded component
|
||||
import { createLazyFileRoute } from '@tanstack/react-router'
|
||||
import { HeavyChartLibrary } from 'heavy-chart-library'
|
||||
import { ComplexDataGrid } from 'complex-data-grid'
|
||||
import { AnalyticsWidgets } from './components/AnalyticsWidgets'
|
||||
|
||||
export const Route = createLazyFileRoute('/dashboard')({
|
||||
component: DashboardPage,
|
||||
pendingComponent: DashboardSkeleton,
|
||||
errorComponent: DashboardError,
|
||||
})
|
||||
|
||||
function DashboardPage() {
|
||||
const data = Route.useLoaderData()
|
||||
return (
|
||||
<div>
|
||||
<HeavyChartLibrary data={data} />
|
||||
<ComplexDataGrid />
|
||||
<AnalyticsWidgets />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DashboardSkeleton() {
|
||||
return <div className="dashboard-skeleton">Loading dashboard...</div>
|
||||
}
|
||||
|
||||
function DashboardError({ error }: { error: Error }) {
|
||||
return <div>Failed to load dashboard: {error.message}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## What Goes Where
|
||||
|
||||
```tsx
|
||||
// Main route file (routes/example.tsx)
|
||||
// - path configuration (implicit from file location)
|
||||
// - validateSearch
|
||||
// - beforeLoad (auth checks, redirects)
|
||||
// - loader (data fetching)
|
||||
// - loaderDeps
|
||||
// - context manipulation
|
||||
// - Static route data
|
||||
|
||||
// Lazy file (routes/example.lazy.tsx)
|
||||
// - component
|
||||
// - pendingComponent
|
||||
// - errorComponent
|
||||
// - notFoundComponent
|
||||
```
|
||||
|
||||
## Using getRouteApi in Lazy Components
|
||||
|
||||
```tsx
|
||||
// routes/posts/$postId.lazy.tsx
|
||||
import { createLazyFileRoute, getRouteApi } from '@tanstack/react-router'
|
||||
|
||||
const route = getRouteApi('/posts/$postId')
|
||||
|
||||
export const Route = createLazyFileRoute('/posts/$postId')({
|
||||
component: PostPage,
|
||||
})
|
||||
|
||||
function PostPage() {
|
||||
// Type-safe access without importing main route file
|
||||
const { postId } = route.useParams()
|
||||
const data = route.useLoaderData()
|
||||
|
||||
return <article>{/* ... */}</article>
|
||||
}
|
||||
```
|
||||
|
||||
## Automatic Code Splitting
|
||||
|
||||
```tsx
|
||||
// vite.config.ts - Enable automatic splitting
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite({
|
||||
autoCodeSplitting: true, // Automatically splits all route components
|
||||
}),
|
||||
react(),
|
||||
],
|
||||
})
|
||||
|
||||
// With autoCodeSplitting, you don't need .lazy.tsx files
|
||||
// The plugin handles the splitting automatically
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Lazy loading reduces initial bundle size significantly
|
||||
- Loaders are NOT lazy - they need to run before rendering
|
||||
- `createLazyFileRoute` only accepts component-related options
|
||||
- Use `getRouteApi()` for type-safe hook access in lazy files
|
||||
- Consider `autoCodeSplitting: true` for simpler setup
|
||||
- Virtual routes auto-generate when only .lazy.tsx exists
|
||||
@@ -0,0 +1,113 @@
|
||||
# ts-register-router: Register Router Type for Global Inference
|
||||
|
||||
## Priority: CRITICAL
|
||||
|
||||
## Explanation
|
||||
|
||||
Register your router instance with TypeScript's module declaration to enable type inference across your entire application. Without registration, hooks like `useNavigate`, `useParams`, and `useSearch` won't know your route structure.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// router.tsx - Missing type registration
|
||||
import { createRouter, createRootRoute } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
// components/Navigation.tsx
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
function Navigation() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// TypeScript doesn't know valid routes - no autocomplete or type checking
|
||||
navigate({ to: '/posts/$postId' }) // No error even if route doesn't exist
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// router.tsx
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export const router = createRouter({ routeTree })
|
||||
|
||||
// Register the router instance for type inference
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
// components/Navigation.tsx
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
function Navigation() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Full type safety - TypeScript knows all valid routes
|
||||
navigate({ to: '/posts/$postId', params: { postId: '123' } })
|
||||
|
||||
// Type error if route doesn't exist
|
||||
navigate({ to: '/invalid-route' }) // Error: Type '"/invalid-route"' is not assignable...
|
||||
|
||||
// Autocomplete for params
|
||||
navigate({
|
||||
to: '/users/$userId/posts/$postId',
|
||||
params: { userId: '1', postId: '2' }, // Both required
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Benefits of Registration
|
||||
|
||||
```tsx
|
||||
// After registration, all these get full type inference:
|
||||
|
||||
// 1. Navigation
|
||||
const navigate = useNavigate()
|
||||
navigate({ to: '/posts/$postId', params: { postId: '123' } })
|
||||
|
||||
// 2. Link component
|
||||
<Link to="/posts/$postId" params={{ postId: '123' }}>View Post</Link>
|
||||
|
||||
// 3. useParams hook
|
||||
const { postId } = useParams({ from: '/posts/$postId' }) // postId: string
|
||||
|
||||
// 4. useSearch hook
|
||||
const search = useSearch({ from: '/posts' }) // Knows search param types
|
||||
|
||||
// 5. useLoaderData hook
|
||||
const data = useLoaderData({ from: '/posts/$postId' }) // Knows loader return type
|
||||
```
|
||||
|
||||
## File-Based Routing Setup
|
||||
|
||||
```tsx
|
||||
// With file-based routing, routeTree is auto-generated
|
||||
// router.tsx
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { routeTree } from './routeTree.gen' // Generated file
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
})
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Must be done once, typically in your router configuration file
|
||||
- Enables IDE autocomplete for routes, params, and search params
|
||||
- Catches invalid routes at compile time
|
||||
- Works with both file-based and code-based routing
|
||||
- Required for full TypeScript benefits of TanStack Router
|
||||
@@ -0,0 +1,130 @@
|
||||
# ts-use-from-param: Use `from` Parameter for Type Narrowing
|
||||
|
||||
## Priority: CRITICAL
|
||||
|
||||
## Explanation
|
||||
|
||||
When using hooks like `useParams`, `useSearch`, or `useLoaderData`, provide the `from` parameter to get exact types for that route. Without it, TypeScript returns a union of all possible types across all routes.
|
||||
|
||||
## Bad Example
|
||||
|
||||
```tsx
|
||||
// Without 'from' - TypeScript doesn't know which route's types to use
|
||||
function PostDetail() {
|
||||
// params could be from ANY route - types are unioned
|
||||
const params = useParams()
|
||||
// params: { postId?: string; userId?: string; categoryId?: string; ... }
|
||||
|
||||
// TypeScript can't guarantee postId exists
|
||||
console.log(params.postId) // postId: string | undefined
|
||||
}
|
||||
|
||||
// Similarly for search params
|
||||
function SearchResults() {
|
||||
const search = useSearch()
|
||||
// search: union of ALL routes' search params
|
||||
}
|
||||
```
|
||||
|
||||
## Good Example
|
||||
|
||||
```tsx
|
||||
// With 'from' - exact types for this specific route
|
||||
function PostDetail() {
|
||||
const params = useParams({ from: '/posts/$postId' })
|
||||
// params: { postId: string } - exactly what this route provides
|
||||
|
||||
console.log(params.postId) // postId: string (guaranteed)
|
||||
}
|
||||
|
||||
// Full path matching
|
||||
function UserPost() {
|
||||
const params = useParams({ from: '/users/$userId/posts/$postId' })
|
||||
// params: { userId: string; postId: string }
|
||||
}
|
||||
|
||||
// Search params with type narrowing
|
||||
function SearchResults() {
|
||||
const search = useSearch({ from: '/search' })
|
||||
// search: exactly the validated search params for /search route
|
||||
}
|
||||
|
||||
// Loader data with type inference
|
||||
function PostPage() {
|
||||
const { post, comments } = useLoaderData({ from: '/posts/$postId' })
|
||||
// Exact types from your loader function
|
||||
}
|
||||
```
|
||||
|
||||
## Using Route.fullPath for Type Safety
|
||||
|
||||
```tsx
|
||||
// routes/posts/$postId.tsx
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/posts/$postId')({
|
||||
loader: async ({ params }) => {
|
||||
const post = await fetchPost(params.postId)
|
||||
return { post }
|
||||
},
|
||||
component: PostComponent,
|
||||
})
|
||||
|
||||
function PostComponent() {
|
||||
// Use Route.fullPath for guaranteed type matching
|
||||
const params = useParams({ from: Route.fullPath })
|
||||
const { post } = useLoaderData({ from: Route.fullPath })
|
||||
|
||||
// Or use route-specific helper (preferred in same file)
|
||||
const { postId } = Route.useParams()
|
||||
const data = Route.useLoaderData()
|
||||
}
|
||||
```
|
||||
|
||||
## Using getRouteApi for Code-Split Components
|
||||
|
||||
```tsx
|
||||
// components/PostDetail.tsx (separate file from route)
|
||||
import { getRouteApi } from '@tanstack/react-router'
|
||||
|
||||
// Get type-safe access without importing the route
|
||||
const postRoute = getRouteApi('/posts/$postId')
|
||||
|
||||
export function PostDetail() {
|
||||
const params = postRoute.useParams()
|
||||
// params: { postId: string }
|
||||
|
||||
const data = postRoute.useLoaderData()
|
||||
// data: exact loader return type
|
||||
|
||||
const search = postRoute.useSearch()
|
||||
// search: exact search param types
|
||||
}
|
||||
```
|
||||
|
||||
## When to Use strict: false
|
||||
|
||||
```tsx
|
||||
// In shared components that work across multiple routes
|
||||
function Breadcrumbs() {
|
||||
// strict: false returns union types but allows component reuse
|
||||
const params = useParams({ strict: false })
|
||||
const location = useLocation()
|
||||
|
||||
// params may or may not have certain values
|
||||
return (
|
||||
<nav>
|
||||
{params.userId && <span>User: {params.userId}</span>}
|
||||
{params.postId && <span>Post: {params.postId}</span>}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
- Always use `from` in route-specific components for exact types
|
||||
- Use `Route.useParams()` / `Route.useLoaderData()` within route files
|
||||
- Use `getRouteApi()` in components split from route files
|
||||
- Use `strict: false` only in truly generic, cross-route components
|
||||
- The `from` path must match exactly (including params like `$postId`)
|
||||
Reference in New Issue
Block a user