Welcome to this comprehensive beginner-friendly tutorial on TanStack Router! We’ll build a fully functional Todo application that demonstrates all of TanStack Router’s core features. By the end of this tutorial, you’ll have a solid understanding of how to use TanStack Router for type-safe routing in React applications.
Introduction
TanStack Router is a modern, type-safe routing solution for React, created by Tanner Linsley (the creator of TanStack Query). It represents a significant evolution from traditional routing libraries, offering powerful features that make building complex applications both safer and more enjoyable.
What Makes TanStack Router Different?
TanStack Router stands out from React Router in several key ways:
Type Safety: TanStack Router provides 100% inferred TypeScript support with automatically generated type definitions for routes, parameters, and search queries. This catches routing errors at compile time rather than runtime.
File-Based Routing: While React Router uses declarative JSX configuration, TanStack Router supports both file-based and code-based routing, with automatic route generation from your folder structure.
Built-in Data Loading: Unlike React Router which requires external libraries, TanStack Router includes integrated SWR caching, route loaders, and intelligent preloading capabilities.
Advanced Search Parameter Handling: TanStack Router offers sophisticated search parameter parsing and validation with automatic JSON serialization and type-safe access.
Key Advantages
- Developer Experience: Comprehensive TypeScript integration eliminates entire categories of routing bugs
- Performance: Automatic code splitting, intelligent preloading, and built-in caching
- Modern Architecture: Designed from the ground up for modern React patterns and best practices
- Powerful Features: Route-level error boundaries, nested layouts, and context sharing
While React Router excels in simplicity and proven reliability, TanStack Router leads in modern development experience and type safety. It’s particularly compelling for TypeScript-heavy applications where compile-time guarantees provide significant value.
Setup
Let’s start by setting up a new React project with TanStack Router. We’ll use Vite for fast development and modern tooling.
Using create-tsrouter-app (Recommended)
The fastest way to get started is using the official starter template:
npx create-tsrouter-app@latest todo-app --template file-router --tailwind
cd todo-app
npm run dev
Install Zod for TypeScript data validation:
npm install zod@^3.23.8
npm install @tanstack/zod-adapter
Your project structure should look like this:
src/
├── routes/ # Route files (generated by default)
├── lib/ # Utilities and API functions
├── components/ # Reusable components
├── main.tsx # App entry point
└── App.css
Basic Routes
Now let’s create our basic route structure. TanStack Router uses file-based routing, where the file structure in your routes directory maps directly to URL paths.
Step 1: Create the Root Route
Create src/routes/__root.tsx (note the double underscores):
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
const RootComponent = () => (
<>
<div className="p-4 border-b">
<h1 className="text-2xl font-bold mb-4">Todo App</h1>
<nav className="flex gap-4">
<Link to="/" className="[&.active]:font-bold text-blue-600 hover:underline">
Home
</Link>
<Link to="/todos" className="[&.active]:font-bold text-blue-600 hover:underline">
Todos
</Link>
<Link to="/about" className="[&.active]:font-bold text-blue-600 hover:underline">
About
</Link>
</nav>
</div>
<div className="p-4">
<Outlet />
</div>
<TanStackRouterDevtools />
</>
)
export const Route = createRootRoute({
component: RootComponent,
})
Step 2: Create the Home Page
Create src/routes/index.tsx:
import { createFileRoute } from '@tanstack/react-router'
function Home() {
return (
<div>
<h2 className="text-xl font-semibold mb-4">Welcome to Todo App</h2>
<p className="text-gray-600">
This is a demo application showcasing TanStack Router features.
Navigate to the Todos section to start managing your tasks!
</p>
</div>
)
}
export const Route = createFileRoute('/')({
component: Home,
})
Step 3: Create the About Page
Create src/routes/about.tsx:
import { createFileRoute } from '@tanstack/react-router'
function About() {
return (
<div>
<h2 className="text-xl font-semibold mb-4">About This App</h2>
<p className="text-gray-600 mb-4">
This Todo application demonstrates the powerful features of TanStack Router:
</p>
<ul className="list-disc pl-6 text-gray-600 space-y-2">
<li>Type-safe routing with automatic TypeScript inference</li>
<li>File-based routing with nested layouts</li>
<li>Built-in data loading and caching</li>
<li>Error boundaries and 404 handling</li>
<li>Search parameter validation</li>
</ul>
</div>
)
}
export const Route = createFileRoute('/about')({
component: About,
})
Step 4: Set Up the Router
Update your src/main.tsx:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import "./styles.css";
// Import the generated route tree
import { routeTree } from "./routeTree.gen";
// Create a new router instance
const router = createRouter({ routeTree });
// Register the router instance for type safety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
// Render the app
const rootElement = document.getElementById("app")!;
createRoot(rootElement).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
At this point, you should be able to run npm run dev and see your basic routing working with navigation between Home, Todos (we’ll create this next), and About pages.
Nested Routes
Now let’s implement nested routes for our Todo functionality. We’ll create a todos section with a list page and individual todo detail pages using the pattern /todos/:todoId.
Step 1: Create Types and Mock API
First, let’s create our data types and mock API. Create src/lib/types.ts:
export interface Todo {
id: number
title: string
description: string
completed: boolean
createdAt: string
}
export interface CreateTodoData {
title: string
description: string
}
export interface UpdateTodoData {
title?: string
description?: string
completed?: boolean
}
Create src/lib/api.ts for our mock API functions:
import type { Todo, CreateTodoData, UpdateTodoData } from "./types";
// Mock data
let todos: Todo[] = [
{
id: 1,
title: 'Learn TanStack Router',
description: 'Complete the tutorial and understand all core concepts',
completed: false,
createdAt: '2024-01-01T10:00:00Z',
},
{
id: 2,
title: 'Build a React app',
description: 'Create a new React application with modern routing',
completed: true,
createdAt: '2024-01-02T14:30:00Z',
},
{
id: 3,
title: 'Deploy to production',
description: 'Deploy the finished app to Vercel or Netlify',
completed: false,
createdAt: '2024-01-03T09:15:00Z',
},
]
let nextId = 4
// Simulate network delay
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
export const api = {
// Fetch all todos
async getTodos(): Promise<Todo[]> {
await delay(300)
return [...todos]
},
// Fetch a single todo
async getTodo(id: number): Promise<Todo | null> {
await delay(200)
return todos.find(todo => todo.id === id) || null
},
// Create a new todo
async createTodo(data: CreateTodoData): Promise<Todo> {
await delay(400)
const newTodo: Todo = {
id: nextId++,
title: data.title,
description: data.description,
completed: false,
createdAt: new Date().toISOString(),
}
todos.push(newTodo)
return newTodo
},
// Update a todo
async updateTodo(id: number, data: UpdateTodoData): Promise<Todo | null> {
await delay(300)
const index = todos.findIndex(todo => todo.id === id)
if (index === -1) return null
todos[index] = { ...todos[index], ...data }
return todos[index]
},
// Delete a todo
async deleteTodo(id: number): Promise<boolean> {
await delay(250)
const index = todos.findIndex(todo => todo.id === id)
if (index === -1) return false
todos.splice(index, 1)
return true
},
}
Step 2: Create Todos Layout Route
Create the directory structure for nested todos routes:
mkdir src/routes/todos
Create src/routes/todos/route.tsx (this is the layout route):
import { createFileRoute, Outlet } from '@tanstack/react-router'
function TodosLayout() {
return (
<div>
<h2 className="text-xl font-semibold mb-6">Todo Management</h2>
<Outlet />
</div>
)
}
export const Route = createFileRoute('/todos')({
component: TodosLayout,
})
Step 3: Create Todos List Page
Create src/routes/todos/index.tsx:
import { createFileRoute, Link } from '@tanstack/react-router'
import { api } from '../../lib/api'
function TodosList() {
const todos = Route.useLoaderData()
return (
<div>
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-medium">Your Todos</h3>
<Link
to="/todos/new"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Add New Todo
</Link>
</div>
{todos.length === 0 ? (
<p className="text-gray-500">No todos yet. Create your first one!</p>
) : (
<div className="space-y-4">
{todos.map(todo => (
<div
key={todo.id}
className="border p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className={`font-medium ${todo.completed ? 'line-through text-gray-500' : ''}`}>
{todo.title}
</h4>
<p className="text-gray-600 text-sm mt-1">{todo.description}</p>
<span className={`inline-block px-2 py-1 text-xs rounded mt-2 ${
todo.completed
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{todo.completed ? 'Completed' : 'Pending'}
</span>
</div>
<Link
to="/todos/$todoId"
params={{ todoId: todo.id.toString() }}
className="text-blue-600 hover:underline ml-4"
>
View Details
</Link>
</div>
</div>
))}
</div>
)}
</div>
)
}
export const Route = createFileRoute('/todos/')({
loader: () => api.getTodos(),
component: TodosList,
})
Step 4: Create Individual Todo Detail Page
Create src/routes/todos/$todoId.tsx for the nested route with parameter:
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { api } from '../../lib/api'
function TodoDetail() {
const navigate = useNavigate()
const todo = Route.useLoaderData()
const { todoId } = Route.useParams()
const handleDelete = async () => {
if (confirm('Are you sure you want to delete this todo?')) {
await api.deleteTodo(Number(todoId))
navigate({ to: '/todos' })
}
}
const handleToggleComplete = async () => {
await api.updateTodo(Number(todoId), { completed: !todo.completed })
// Invalidate and reload the current route to get fresh data
navigate({ to: '/todos/$todoId', params: { todoId } })
}
if (!todo) {
return (
<div className="text-center py-8">
<h3 className="text-lg font-medium text-gray-900">Todo not found</h3>
<Link to="/todos" className="text-blue-600 hover:underline">
Back to todos
</Link>
</div>
)
}
return (
<div>
<div className="mb-6">
<Link to="/todos" className="text-blue-600 hover:underline">
← Back to todos
</Link>
</div>
<div className="bg-white border rounded-lg p-6 shadow-sm">
<div className="flex items-start justify-between mb-4">
<h3 className={`text-xl font-semibold ${todo.completed ? 'line-through text-gray-500' : ''}`}>
{todo.title}
</h3>
<span className={`px-3 py-1 text-sm rounded-full ${
todo.completed
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{todo.completed ? 'Completed' : 'Pending'}
</span>
</div>
<p className="text-gray-700 mb-4">{todo.description}</p>
<p className="text-sm text-gray-500 mb-6">
Created: {new Date(todo.createdAt).toLocaleDateString()}
</p>
<div className="flex gap-3">
<button
onClick={handleToggleComplete}
className={`px-4 py-2 rounded ${
todo.completed
? 'bg-yellow-600 text-white hover:bg-yellow-700'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
Mark as {todo.completed ? 'Pending' : 'Completed'}
</button>
<Link
to="/todos/$todoId/edit"
params={{ todoId: todoId }}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Edit
</Link>
<button
onClick={handleDelete}
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)
}
export const Route = createFileRoute('/todos/$todoId')({
loader: ({ params }) => api.getTodo(Number(params.todoId)),
component: TodoDetail,
})
This creates a powerful nested routing structure where:
/todosshows the layout with a list of todos/todos/123shows the details for todo with ID 123- The layout persists across both routes, demonstrating TanStack Router’s nested routing capabilities
Data Loading
TanStack Router provides powerful built-in data loading capabilities through route loaders. These functions run before a route renders, ensuring data is available immediately without loading states in your components.
Understanding Route Loaders
Route loaders are functions that execute when a route match is loaded. They provide several key benefits:
- No “flash of loading” states - data is ready when components render
- Parallel data fetching - multiple loaders run simultaneously
- Built-in SWR caching - automatic caching with stale-while-revalidate strategy
- Type safety - full TypeScript integration with inferred return types
Step 1: Basic Loader Implementation
We’ve already seen basic loaders in our previous examples. Let’s enhance them with more advanced features. Update src/routes/todos/index.tsx:
import { createFileRoute, Link } from '@tanstack/react-router'
import { api } from '../../lib/api'
function TodosList() {
const todos = Route.useLoaderData()
return (
<div>
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-medium">Your Todos ({todos.length})</h3>
<Link
to="/todos/new"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Add New Todo
</Link>
</div>
{todos.length === 0 ? (
<p className="text-gray-500">No todos yet. Create your first one!</p>
) : (
<div className="space-y-4">
{todos.map(todo => (
<div
key={todo.id}
className="border p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className={`font-medium ${todo.completed ? 'line-through text-gray-500' : ''}`}>
{todo.title}
</h4>
<p className="text-gray-600 text-sm mt-1">{todo.description}</p>
<span className={`inline-block px-2 py-1 text-xs rounded mt-2 ${
todo.completed
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{todo.completed ? 'Completed' : 'Pending'}
</span>
</div>
<div className="flex gap-2">
<Link
to="/todos/$todoId"
params={{ todoId: todo.id.toString() }}
className="text-blue-600 hover:underline"
>
View
</Link>
<Link
to="/todos/$todoId/edit"
params={{ todoId: todo.id.toString() }}
className="text-green-600 hover:underline"
>
Edit
</Link>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
export const Route = createFileRoute('/todos/')({
// Loader function with caching
loader: async () => {
console.log('Loading todos...')
return api.getTodos()
},
// Configure caching behavior
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
component: TodosList,
})
Step 2: Loader with Search Parameters
Let’s add filtering capabilities using search parameters. Update src/routes/todos/index.tsx to support filtering:
import { createFileRoute, Link } from '@tanstack/react-router'
import { z } from 'zod'
import { zodValidator } from '@tanstack/zod-adapter'
import { api } from '../../lib/api'
// Define search parameter schema
const todosSearchSchema = z.object({
filter: z.enum(['all', 'completed', 'pending']).default('all'),
search: z.string().default(''),
})
type TodosSearch = z.infer<typeof todosSearchSchema>
function TodosList() {
const todos = Route.useLoaderData()
const { filter, search } = Route.useSearch()
return (
<div>
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-medium">Your Todos ({todos.length})</h3>
<Link
to="/todos/new"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Add New Todo
</Link>
</div>
{/* Filter Controls */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<div className="flex gap-4 items-center">
<div>
<label className="text-sm font-medium text-gray-700">Filter:</label>
<div className="flex gap-2 mt-1">
{(['all', 'completed', 'pending'] as const).map(filterOption => (
<Link
key={filterOption}
to="/todos"
search={{ filter: filterOption, search }}
className={`px-3 py-1 text-sm rounded ${
filter === filterOption
? 'bg-blue-600 text-white'
: 'bg-white border hover:bg-gray-50'
}`}
>
{filterOption.charAt(0).toUpperCase() + filterOption.slice(1)}
</Link>
))}
</div>
</div>
<div className="flex-1">
<label className="text-sm font-medium text-gray-700">Search:</label>
<input
type="text"
value={search}
onChange={(e) => {
// Navigate with updated search parameter
window.history.pushState(
{},
'',
`/todos?filter=${filter}&search=${encodeURIComponent(e.target.value)}`
)
window.location.reload() // In a real app, you'd use proper navigation
}}
placeholder="Search todos..."
className="mt-1 block w-full px-3 py-1 border border-gray-300 rounded text-sm"
/>
</div>
</div>
</div>
{todos.length === 0 ? (
<p className="text-gray-500">No todos match your criteria.</p>
) : (
<div className="space-y-4">
{todos.map(todo => (
<div
key={todo.id}
className="border p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className={`font-medium ${todo.completed ? 'line-through text-gray-500' : ''}`}>
{todo.title}
</h4>
<p className="text-gray-600 text-sm mt-1">{todo.description}</p>
<span className={`inline-block px-2 py-1 text-xs rounded mt-2 ${
todo.completed
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{todo.completed ? 'Completed' : 'Pending'}
</span>
</div>
<div className="flex gap-2">
<Link
to="/todos/$todoId"
params={{ todoId: todo.id.toString() }}
className="text-blue-600 hover:underline"
>
View
</Link>
<Link
to="/todos/$todoId/edit"
params={{ todoId: todo.id.toString() }}
className="text-green-600 hover:underline"
>
Edit
</Link>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
export const Route = createFileRoute('/todos/')({
// Validate search parameters
validateSearch: zodValidator(todosSearchSchema),
// Use search parameters as loader dependencies
loaderDeps: ({ search: { filter, search } }) => ({ filter, search }),
// Loader function that uses search parameters
loader: async ({ deps: { filter, search } }) => {
console.log('Loading todos with filter:', filter, 'search:', search)
let todos = await api.getTodos()
// Apply filtering
if (filter !== 'all') {
todos = todos.filter(todo =>
filter === 'completed' ? todo.completed : !todo.completed
)
}
// Apply search
if (search) {
const searchLower = search.toLowerCase()
todos = todos.filter(todo =>
todo.title.toLowerCase().includes(searchLower) ||
todo.description.toLowerCase().includes(searchLower)
)
}
return todos
},
// Configure caching - reload when dependencies change
staleTime: 2 * 60 * 1000, // 2 minutes
component: TodosList,
})
Step 3: Advanced Loader Features
Let’s add error handling and loading states to our individual todo route. Update src/routes/todos/$todoId.tsx:
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { api } from '../../lib/api'
function TodoDetail() {
const navigate = useNavigate()
const todo = Route.useLoaderData()
const { todoId } = Route.useParams()
// ... component logic (same as before)
}
// Loading component displayed while loader runs
function TodoDetailPending() {
return (
<div className="animate-pulse">
<div className="mb-6">
<div className="h-4 bg-gray-200 rounded w-20"></div>
</div>
<div className="bg-white border rounded-lg p-6 shadow-sm">
<div className="h-6 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-2/3 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-32 mb-6"></div>
<div className="flex gap-3">
<div className="h-8 bg-gray-200 rounded w-20"></div>
<div className="h-8 bg-gray-200 rounded w-16"></div>
<div className="h-8 bg-gray-200 rounded w-16"></div>
</div>
</div>
</div>
)
}
// Error component displayed when loader fails
function TodoDetailError({ error, reset }: { error: Error; reset: () => void }) {
return (
<div className="text-center py-8">
<h3 className="text-lg font-medium text-red-600 mb-2">Failed to load todo</h3>
<p className="text-gray-600 mb-4">{error.message}</p>
<div className="space-x-3">
<button
onClick={reset}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Try Again
</button>
<Link to="/todos" className="text-blue-600 hover:underline">
Back to todos
</Link>
</div>
</div>
)
}
export const Route = createFileRoute('/todos/$todoId')({
// Loader with error handling
loader: async ({ params }) => {
const todo = await api.getTodo(Number(params.todoId))
if (!todo) {
throw new Error(`Todo with ID ${params.todoId} not found`)
}
return todo
},
// Configure loading and error states
pendingComponent: TodoDetailPending,
errorComponent: TodoDetailError,
// Show pending component after 500ms
pendingMs: 500,
pendingMinMs: 200,
// Configure caching
staleTime: 30 * 1000, // 30 seconds
component: TodoDetail,
})
This demonstrates TanStack Router’s comprehensive data loading features:
- Automatic caching with configurable staleness
- Search parameter integration with type-safe validation
- Loading states with customizable pending components
- Error handling with recovery mechanisms
- Dependency tracking for efficient cache invalidation
The router coordinates all data loading, ensuring optimal performance and user experience.
Mutations/Actions
TanStack Router focuses primarily on routing and data loading, but it integrates seamlessly with external mutation libraries. For our Todo app, we’ll implement mutations using the router’s invalidation system to keep data in sync.
Understanding Router Invalidation
When data changes through mutations, we need to tell TanStack Router to refresh cached data. The router.invalidate() method is the key mechanism for this.
Step 1: Create Todo Form Component
First, let’s create a reusable form component. Create src/components/TodoForm.tsx:
import { useState } from 'react'
import type { CreateTodoData, UpdateTodoData, Todo } from "../lib/types";
interface TodoFormProps {
todo?: Todo
onSubmit: (data: CreateTodoData | UpdateTodoData) => Promise<void>
onCancel: () => void
isLoading?: boolean
}
export function TodoForm({ todo, onSubmit, onCancel, isLoading }: TodoFormProps) {
const [title, setTitle] = useState(todo?.title || '')
const [description, setDescription] = useState(todo?.description || '')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) return
await onSubmit({
title: title.trim(),
description: description.trim(),
})
}
return (
<form onSubmit={handleSubmit} className="bg-white border rounded-lg p-6 shadow-sm">
<div className="mb-4">
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-2">
Title *
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter todo title..."
required
disabled={isLoading}
/>
</div>
<div className="mb-6">
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-2">
Description
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter todo description..."
disabled={isLoading}
/>
</div>
<div className="flex gap-3">
<button
type="submit"
disabled={isLoading || !title.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Saving...' : todo ? 'Update Todo' : 'Create Todo'}
</button>
<button
type="button"
onClick={onCancel}
disabled={isLoading}
className="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400 disabled:opacity-50"
>
Cancel
</button>
</div>
</form>
)
}
Step 2: Create New Todo Route
Create src/routes/todos/new.tsx:
import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { TodoForm } from '../../components/TodoForm'
import { api } from '../../lib/api'
import type { CreateTodoData } from "../../lib/types";
function NewTodo() {
const navigate = useNavigate()
const router = useRouter()
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (data: CreateTodoData) => {
setIsLoading(true)
try {
const newTodo = await api.createTodo(data)
// Invalidate todos list to refresh the cache
await router.invalidate()
// Navigate to the todos list
navigate({ to: '/todos' })
} catch (error) {
console.error('Failed to create todo:', error)
alert('Failed to create todo. Please try again.')
} finally {
setIsLoading(false)
}
}
const handleCancel = () => {
navigate({ to: '/todos' })
}
return (
<div>
<div className="mb-6">
<h3 className="text-lg font-medium">Create New Todo</h3>
</div>
<TodoForm
onSubmit={handleSubmit}
onCancel={handleCancel}
isLoading={isLoading}
/>
</div>
)
}
export const Route = createFileRoute('/todos/new')({
component: NewTodo,
})
Step 3: Create Edit Todo Route
Create src/routes/todos/$todoId.edit.tsx:
import { createFileRoute, useNavigate, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { TodoForm } from '../../components/TodoForm'
import { api } from '../../lib/api'
import type { UpdateTodoData } from "../../lib/types";
function EditTodo() {
const navigate = useNavigate()
const router = useRouter()
const todo = Route.useLoaderData()
const { todoId } = Route.useParams()
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (data: UpdateTodoData) => {
setIsLoading(true)
try {
await api.updateTodo(Number(todoId), data)
// Invalidate all cached data to refresh
await router.invalidate()
// Navigate back to todo detail
navigate({ to: '/todos/$todoId', params: { todoId } })
} catch (error) {
console.error('Failed to update todo:', error)
alert('Failed to update todo. Please try again.')
} finally {
setIsLoading(false)
}
}
const handleCancel = () => {
navigate({ to: '/todos/$todoId', params: { todoId } })
}
if (!todo) {
return (
<div className="text-center py-8">
<h3 className="text-lg font-medium text-red-600">Todo not found</h3>
</div>
)
}
return (
<div>
<div className="mb-6">
<h3 className="text-lg font-medium">Edit Todo</h3>
</div>
<TodoForm
todo={todo}
onSubmit={handleSubmit}
onCancel={handleCancel}
isLoading={isLoading}
/>
</div>
)
}
export const Route = createFileRoute('/todos/$todoId/edit')({
loader: ({ params }) => api.getTodo(Number(params.todoId)),
component: EditTodo,
})
Step 4: Enhanced Todo Detail with Mutations
Update src/routes/todos/$todoId.tsx to include better mutation handling:
import { createFileRoute, Link, useNavigate, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { api } from '../../lib/api'
function TodoDetail() {
const navigate = useNavigate()
const router = useRouter()
const todo = Route.useLoaderData()
const { todoId } = Route.useParams()
const [isUpdating, setIsUpdating] = useState(false)
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this todo?')) return
setIsUpdating(true)
try {
await api.deleteTodo(Number(todoId))
// Invalidate cache and navigate to todos list
await router.invalidate()
navigate({ to: '/todos' })
} catch (error) {
console.error('Failed to delete todo:', error)
alert('Failed to delete todo. Please try again.')
} finally {
setIsUpdating(false)
}
}
const handleToggleComplete = async () => {
setIsUpdating(true)
try {
await api.updateTodo(Number(todoId), { completed: !todo.completed })
// Invalidate current route to reload data
await router.invalidate()
} catch (error) {
console.error('Failed to update todo:', error)
alert('Failed to update todo. Please try again.')
} finally {
setIsUpdating(false)
}
}
if (!todo) {
return (
<div className="text-center py-8">
<h3 className="text-lg font-medium text-gray-900">Todo not found</h3>
<Link to="/todos" className="text-blue-600 hover:underline">
Back to todos
</Link>
</div>
)
}
return (
<div>
<div className="mb-6">
<Link to="/todos" className="text-blue-600 hover:underline">
← Back to todos
</Link>
</div>
<div className="bg-white border rounded-lg p-6 shadow-sm">
<div className="flex items-start justify-between mb-4">
<h3 className={`text-xl font-semibold ${todo.completed ? 'line-through text-gray-500' : ''}`}>
{todo.title}
</h3>
<span className={`px-3 py-1 text-sm rounded-full ${
todo.completed
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{todo.completed ? 'Completed' : 'Pending'}
</span>
</div>
<p className="text-gray-700 mb-4">{todo.description}</p>
<p className="text-sm text-gray-500 mb-6">
Created: {new Date(todo.createdAt).toLocaleDateString()}
</p>
<div className="flex gap-3">
<button
onClick={handleToggleComplete}
disabled={isUpdating}
className={`px-4 py-2 rounded disabled:opacity-50 ${
todo.completed
? 'bg-yellow-600 text-white hover:bg-yellow-700'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{isUpdating ? 'Updating...' : `Mark as ${todo.completed ? 'Pending' : 'Completed'}`}
</button>
<Link
to="/todos/$todoId/edit"
params={{ todoId: todoId }}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Edit
</Link>
<button
onClick={handleDelete}
disabled={isUpdating}
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 disabled:opacity-50"
>
{isUpdating ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)
}
// Error and loading components remain the same...
function TodoDetailPending() {
return (
<div className="animate-pulse">
<div className="mb-6">
<div className="h-4 bg-gray-200 rounded w-20"></div>
</div>
<div className="bg-white border rounded-lg p-6 shadow-sm">
<div className="h-6 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-2/3 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-32 mb-6"></div>
<div className="flex gap-3">
<div className="h-8 bg-gray-200 rounded w-20"></div>
<div className="h-8 bg-gray-200 rounded w-16"></div>
<div className="h-8 bg-gray-200 rounded w-16"></div>
</div>
</div>
</div>
)
}
function TodoDetailError({ error, reset }: { error: Error; reset: () => void }) {
return (
<div className="text-center py-8">
<h3 className="text-lg font-medium text-red-600 mb-2">Failed to load todo</h3>
<p className="text-gray-600 mb-4">{error.message}</p>
<div className="space-x-3">
<button
onClick={reset}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Try Again
</button>
<Link to="/todos" className="text-blue-600 hover:underline">
Back to todos
</Link>
</div>
</div>
)
}
export const Route = createFileRoute('/todos/$todoId')({
loader: async ({ params }) => {
const todo = await api.getTodo(Number(params.todoId))
if (!todo) {
throw new Error(`Todo with ID ${params.todoId} not found`)
}
return todo
},
pendingComponent: TodoDetailPending,
errorComponent: TodoDetailError,
pendingMs: 500,
pendingMinMs: 200,
staleTime: 30 * 1000,
component: TodoDetail,
})
Key Mutation Patterns
- Router Invalidation: Use
router.invalidate()to refresh cached data after mutations - Loading States: Track mutation loading states in component state
- Error Handling: Provide user feedback for failed mutations
- Optimistic Updates: For better UX, you could update local state immediately and revert on failure
- Cache Coordination: The router ensures all affected routes refresh their data
This approach keeps TanStack Router’s built-in caching in sync with your application’s data mutations while maintaining optimal performance.
Navigation
TanStack Router provides powerful navigation capabilities through the Link component and programmatic navigation hooks. Let’s explore different navigation patterns and enhance our Todo app with better navigation UX.
Understanding TanStack Router Navigation
Every navigation in TanStack Router is relative, meaning you’re always navigating from one route to another. This concept enables powerful type-safe navigation and auto-completion.
Step 1: Enhanced Link Components
TanStack Router’s Link component provides type-safe navigation with automatic href generation. Let’s enhance our navigation throughout the app.
Update src/routes/__root.tsx with improved navigation:
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
const RootComponent = () => (
<>
<header className="bg-blue-600 text-white shadow-sm">
<div className="max-w-6xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<Link to="/" className="text-xl font-bold hover:text-blue-100">
Todo App
</Link>
<nav className="flex gap-6">
<Link
to="/"
className="[&.active]:font-bold [&.active]:text-blue-100 hover:text-blue-200 transition-colors"
activeOptions={{ exact: true }}
>
Home
</Link>
<Link
to="/todos"
className="[&.active]:font-bold [&.active]:text-blue-100 hover:text-blue-200 transition-colors"
>
Todos
</Link>
<Link
to="/about"
className="[&.active]:font-bold [&.active]:text-blue-100 hover:text-blue-200 transition-colors"
>
About
</Link>
</nav>
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-4 py-8">
<Outlet />
</main>
<TanStackRouterDevtools />
</>
)
export const Route = createRootRoute({
component: RootComponent,
})
Step 2: Programmatic Navigation
Let’s create a custom hook for common navigation patterns. Create src/lib/navigation.ts:
import { useNavigate, useRouter } from '@tanstack/react-router'
export function useAppNavigation() {
const navigate = useNavigate()
const router = useRouter()
return {
// Navigate to todos list
toTodosList: () => navigate({ to: '/todos' }),
// Navigate to specific todo
toTodo: (todoId: string | number) =>
navigate({
to: '/todos/$todoId',
params: { todoId: todoId.toString() }
}),
// Navigate to edit todo
toEditTodo: (todoId: string | number) =>
navigate({
to: '/todos/$todoId/edit',
params: { todoId: todoId.toString() }
}),
// Navigate to create new todo
toNewTodo: () => navigate({ to: '/todos/new' }),
// Navigate back with fallback
goBack: (fallbackTo: string = '/todos') => {
if (window.history.length > 1) {
window.history.back()
} else {
navigate({ to: fallbackTo })
}
},
// Navigate with search parameters
toTodosWithFilter: (filter: 'all' | 'completed' | 'pending', search?: string) =>
navigate({
to: '/todos',
search: { filter, search: search || '' }
}),
// Refresh current route
refresh: () => router.invalidate(),
}
}
Step 3: Smart Navigation Components
Create a breadcrumb component for better navigation context. Create src/components/Breadcrumbs.tsx:
import { Link, useMatches } from '@tanstack/react-router'
export function Breadcrumbs() {
const matches = useMatches()
// Build breadcrumb items from route matches
const breadcrumbs = matches
.filter(match => match.pathname !== '/')
.map(match => {
let label = 'Unknown'
let to = match.pathname
if (match.pathname === '/todos') {
label = 'Todos'
} else if (match.pathname === '/about') {
label = 'About'
} else if (match.pathname.startsWith('/todos/') && match.pathname.endsWith('/new')) {
label = 'New Todo'
} else if (match.pathname.startsWith('/todos/') && match.pathname.endsWith('/edit')) {
label = 'Edit Todo'
} else if (match.pathname.startsWith('/todos/') && match.params?.todoId) {
label = `Todo #${match.params.todoId}`
}
return { label, to }
})
if (breadcrumbs.length === 0) return null
return (
<nav className="mb-6">
<ol className="flex items-center space-x-2 text-sm text-gray-500">
<li>
<Link to="/" className="hover:text-gray-700">
Home
</Link>
</li>
{breadcrumbs.map((breadcrumb, index) => (
<li key={breadcrumb.to} className="flex items-center space-x-2">
<span>/</span>
{index === breadcrumbs.length - 1 ? (
<span className="text-gray-900 font-medium">{breadcrumb.label}</span>
) : (
<Link to={breadcrumb.to} className="hover:text-gray-700">
{breadcrumb.label}
</Link>
)}
</li>
))}
</ol>
</nav>
)
}
Step 4: Enhanced Todo List with Navigation
Update src/routes/todos/index.tsx to use better navigation patterns:
import { createFileRoute, Link } from '@tanstack/react-router'
import { z } from 'zod'
import { zodValidator } from '@tanstack/zod-adapter'
import { api } from '../../lib/api'
import { useAppNavigation } from '../../lib/navigation'
import { Breadcrumbs } from "@/components/BreadCrumbs";
const todosSearchSchema = z.object({
filter: z.enum(['all', 'completed', 'pending']).default('all'),
search: z.string().default(''),
})
function TodosList() {
const todos = Route.useLoaderData()
const { filter, search } = Route.useSearch()
const navigation = useAppNavigation()
const handleQuickAction = async (todoId: number, action: 'toggle' | 'delete') => {
if (action === 'toggle') {
const todo = todos.find(t => t.id === todoId)
if (todo) {
await api.updateTodo(todoId, { completed: !todo.completed })
navigation.refresh()
}
} else if (action === 'delete') {
if (confirm('Are you sure you want to delete this todo?')) {
await api.deleteTodo(todoId)
navigation.refresh()
}
}
}
return (
<div>
<Breadcrumbs />
<div className="flex justify-between items-center mb-6">
<h3 className="text-lg font-medium">
Your Todos ({todos.length})
{filter !== 'all' && (
<span className="ml-2 text-sm text-gray-500">
({filter})
</span>
)}
</h3>
<button
onClick={() => navigation.toNewTodo()}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
>
Add New Todo
</button>
</div>
{/* Enhanced Filter Controls */}
<div className="mb-6 p-4 bg-gray-50 rounded-lg">
<div className="flex gap-4 items-center flex-wrap">
<div>
<label className="text-sm font-medium text-gray-700">Filter:</label>
<div className="flex gap-2 mt-1">
{(['all', 'completed', 'pending'] as const).map(filterOption => (
<button
key={filterOption}
onClick={() => navigation.toTodosWithFilter(filterOption, search)}
className={`px-3 py-1 text-sm rounded transition-colors ${
filter === filterOption
? 'bg-blue-600 text-white'
: 'bg-white border hover:bg-gray-50'
}`}
>
{filterOption.charAt(0).toUpperCase() + filterOption.slice(1)}
</button>
))}
</div>
</div>
<div className="flex-1 min-w-64">
<label className="text-sm font-medium text-gray-700">Search:</label>
<input
type="text"
value={search}
onChange={(e) => navigation.toTodosWithFilter(filter, e.target.value)}
placeholder="Search todos..."
className="mt-1 block w-full px-3 py-1 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{(filter !== 'all' || search) && (
<button
onClick={() => navigation.toTodosWithFilter('all', '')}
className="text-sm text-gray-600 hover:text-gray-800"
>
Clear filters
</button>
)}
</div>
</div>
{todos.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 mb-4">
{filter !== 'all' || search
? 'No todos match your criteria.'
: 'No todos yet. Create your first one!'
}
</p>
{filter === 'all' && !search && (
<button
onClick={() => navigation.toNewTodo()}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Create First Todo
</button>
)}
</div>
) : (
<div className="space-y-4">
{todos.map(todo => (
<div
key={todo.id}
className="border p-4 rounded-lg shadow-sm hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className={`font-medium ${todo.completed ? 'line-through text-gray-500' : ''}`}>
{todo.title}
</h4>
<p className="text-gray-600 text-sm mt-1 line-clamp-2">{todo.description}</p>
<div className="flex items-center gap-3 mt-2">
<span className={`inline-block px-2 py-1 text-xs rounded ${
todo.completed
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{todo.completed ? 'Completed' : 'Pending'}
</span>
<span className="text-xs text-gray-500">
Created {new Date(todo.createdAt).toLocaleDateString()}
</span>
</div>
</div>
<div className="flex flex-col gap-2 ml-4">
<div className="flex gap-2">
<Link
to="/todos/$todoId"
params={{ todoId: todo.id.toString() }}
className="text-blue-600 hover:underline text-sm"
>
View
</Link>
<Link
to="/todos/$todoId/edit"
params={{ todoId: todo.id.toString() }}
className="text-green-600 hover:underline text-sm"
>
Edit
</Link>
</div>
<div className="flex gap-2">
<button
onClick={() => handleQuickAction(todo.id, 'toggle')}
className="text-xs text-purple-600 hover:underline"
>
{todo.completed ? 'Mark Pending' : 'Mark Done'}
</button>
<button
onClick={() => handleQuickAction(todo.id, 'delete')}
className="text-xs text-red-600 hover:underline"
>
Delete
</button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}
export const Route = createFileRoute('/todos/')({
validateSearch: zodValidator(todosSearchSchema),
loaderDeps: ({ search: { filter, search } }) => ({ filter, search }),
loader: async ({ deps: { filter, search } }) => {
let todos = await api.getTodos()
if (filter !== 'all') {
todos = todos.filter(todo =>
filter === 'completed' ? todo.completed : !todo.completed
)
}
if (search) {
const searchLower = search.toLowerCase()
todos = todos.filter(todo =>
todo.title.toLowerCase().includes(searchLower) ||
todo.description.toLowerCase().includes(searchLower)
)
}
return todos
},
staleTime: 2 * 60 * 1000,
component: TodosList,
})
Navigation Best Practices
- Type Safety: TanStack Router provides full TypeScript support for navigation parameters
- Relative Navigation: Always consider the
fromandtorelationship for predictable navigation - Search Parameters: Use type-safe search parameter handling for filters and state
- Programmatic Navigation: Combine
useNavigatewith custom hooks for reusable navigation logic - User Experience: Provide clear navigation paths and breadcrumbs for complex applications
This comprehensive navigation setup provides users with intuitive ways to move through your application while maintaining type safety and optimal performance.
Error Handling
TanStack Router provides comprehensive error handling capabilities, including custom error components, error boundaries, and 404 page handling. Let’s implement robust error handling for our Todo app.
Understanding TanStack Router Error Handling
TanStack Router has built-in error boundaries that catch errors during:
- Route loading (loader functions)
- Component rendering
- Navigation processes
The router provides several levels of error handling:
- Route-level error components - Handle errors for specific routes
- Default error component - Global fallback for unhandled errors
- Not found handling - Custom 404 pages
- Error recovery - Mechanisms to retry failed operations
Step 1: Create Custom Error Components
Create src/components/ErrorComponents.tsx:
import { Link, useRouter } from '@tanstack/react-router'
import { ErrorComponent } from '@tanstack/react-router'
interface CustomErrorProps {
error: Error
reset?: () => void
info?: {
componentStack: string
}
}
export function TodoErrorFallback({ error, reset, info }: CustomErrorProps) {
const router = useRouter()
const handleRetry = async () => {
if (reset) {
reset()
} else {
// Fallback to router invalidation
await router.invalidate()
}
}
return (
<div className="min-h-64 flex items-center justify-center">
<div className="max-w-md mx-auto text-center">
<div className="mb-6">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
Oops! Something went wrong
</h3>
<p className="text-gray-600 mb-4">
{error.message || 'An unexpected error occurred while loading this page.'}
</p>
</div>
<div className="space-y-3">
<div className="flex gap-3 justify-center">
<button
onClick={handleRetry}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
>
Try Again
</button>
<Link
to="/todos"
className="bg-gray-100 text-gray-700 px-4 py-2 rounded hover:bg-gray-200 transition-colors"
>
Go to Todos
</Link>
<Link
to="/"
className="text-blue-600 hover:underline px-4 py-2"
>
Go Home
</Link>
</div>
{process.env.NODE_ENV === 'development' && (
<details className="mt-6 text-left">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
Error Details (Development)
</summary>
<div className="mt-2 p-3 bg-gray-50 rounded text-xs font-mono text-gray-800">
<div className="mb-2">
<strong>Error:</strong> {error.message}
</div>
<div className="mb-2">
<strong>Stack:</strong>
<pre className="mt-1 whitespace-pre-wrap">{error.stack}</pre>
</div>
{info?.componentStack && (
<div>
<strong>Component Stack:</strong>
<pre className="mt-1 whitespace-pre-wrap">{info.componentStack}</pre>
</div>
)}
</div>
</details>
)}
</div>
</div>
</div>
)
}
export function NotFoundComponent() {
return (
<div className="min-h-64 flex items-center justify-center">
<div className="max-w-md mx-auto text-center">
<div className="mb-6">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 12h6m-6-4h6m2 5.291A7.962 7.962 0 0112 15c-2.87 0-5.431 1.5-6.873 3.797M3 12a9 9 0 1118 0 9 9 0 01-18 0z" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
Page Not Found
</h3>
<p className="text-gray-600 mb-4">
The page you're looking for doesn't exist or has been moved.
</p>
</div>
<div className="flex gap-3 justify-center">
<Link
to="/"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
>
Go Home
</Link>
<Link
to="/todos"
className="bg-gray-100 text-gray-700 px-4 py-2 rounded hover:bg-gray-200 transition-colors"
>
Browse Todos
</Link>
</div>
</div>
</div>
)
}
export function LoadingError({ error, reset }: { error: Error; reset: () => void }) {
return (
<div className="text-center py-8">
<h3 className="text-lg font-medium text-red-600 mb-2">Failed to load data</h3>
<p className="text-gray-600 mb-4">{error.message}</p>
<div className="space-x-3">
<button
onClick={reset}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
Try Again
</button>
<Link to="/todos" className="text-blue-600 hover:underline">
Back to todos
</Link>
</div>
</div>
)
}
Step 2: Add Global Error Handling
Update src/routes/__root.tsx to include global error handling:
import { createRootRoute, Link, Outlet } from '@tanstack/react-router'
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
import { TodoErrorFallback, NotFoundComponent } from '../components/ErrorComponents'
const RootComponent = () => (
<>
<header className="bg-blue-600 text-white shadow-sm">
<div className="max-w-6xl mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<Link to="/" className="text-xl font-bold hover:text-blue-100">
Todo App
</Link>
<nav className="flex gap-6">
<Link
to="/"
className="[&.active]:font-bold [&.active]:text-blue-100 hover:text-blue-200 transition-colors"
activeOptions={{ exact: true }}
>
Home
</Link>
<Link
to="/todos"
className="[&.active]:font-bold [&.active]:text-blue-100 hover:text-blue-200 transition-colors"
>
Todos
</Link>
<Link
to="/about"
className="[&.active]:font-bold [&.active]:text-blue-100 hover:text-blue-200 transition-colors"
>
About
</Link>
</nav>
</div>
</div>
</header>
<main className="max-w-6xl mx-auto px-4 py-8 min-h-screen">
<Outlet />
</main>
<TanStackRouterDevtools />
</>
)
export const Route = createRootRoute({
component: RootComponent,
// Global error component for unhandled errors
errorComponent: TodoErrorFallback,
// Global not found component
notFoundComponent: NotFoundComponent,
})
Step 3: Route-Specific Error Handling
Update src/routes/todos/$todoId.tsx with enhanced error handling:
import { createFileRoute, Link, useNavigate, useRouter } from '@tanstack/react-router'
import { useState } from 'react'
import { api } from '../../lib/api'
import { LoadingError } from '../../components/ErrorComponents'
import { Breadcrumbs } from '../../components/BreadCrumbs'
function TodoDetail() {
const navigate = useNavigate()
const router = useRouter()
const todo = Route.useLoaderData()
const { todoId } = Route.useParams()
const [isUpdating, setIsUpdating] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this todo?')) return
setIsUpdating(true)
setError(null)
try {
const success = await api.deleteTodo(Number(todoId))
if (!success) {
throw new Error('Failed to delete todo')
}
await router.invalidate()
navigate({ to: '/todos' })
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete todo')
} finally {
setIsUpdating(false)
}
}
const handleToggleComplete = async () => {
setIsUpdating(true)
setError(null)
try {
const updated = await api.updateTodo(Number(todoId), { completed: !todo.completed })
if (!updated) {
throw new Error('Failed to update todo')
}
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update todo')
} finally {
setIsUpdating(false)
}
}
return (
<div>
<Breadcrumbs />
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center">
<svg className="w-5 h-5 text-red-600 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-red-800">{error}</span>
<button
onClick={() => setError(null)}
className="ml-auto text-red-600 hover:text-red-800"
>
×
</button>
</div>
</div>
)}
<div className="bg-white border rounded-lg p-6 shadow-sm">
<div className="flex items-start justify-between mb-4">
<h3 className={`text-xl font-semibold ${todo.completed ? 'line-through text-gray-500' : ''}`}>
{todo.title}
</h3>
<span className={`px-3 py-1 text-sm rounded-full ${
todo.completed
? 'bg-green-100 text-green-800'
: 'bg-yellow-100 text-yellow-800'
}`}>
{todo.completed ? 'Completed' : 'Pending'}
</span>
</div>
<p className="text-gray-700 mb-4">{todo.description}</p>
<p className="text-sm text-gray-500 mb-6">
Created: {new Date(todo.createdAt).toLocaleDateString()}
</p>
<div className="flex gap-3">
<button
onClick={handleToggleComplete}
disabled={isUpdating}
className={`px-4 py-2 rounded disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
todo.completed
? 'bg-yellow-600 text-white hover:bg-yellow-700'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{isUpdating ? 'Updating...' : `Mark as ${todo.completed ? 'Pending' : 'Completed'}`}
</button>
<Link
to="/todos/$todoId/edit"
params={{ todoId: todoId }}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors"
>
Edit
</Link>
<button
onClick={handleDelete}
disabled={isUpdating}
className="bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isUpdating ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)
}
function TodoDetailPending() {
return (
<div>
<div className="mb-6">
<div className="h-4 bg-gray-200 rounded w-20 animate-pulse"></div>
</div>
<div className="bg-white border rounded-lg p-6 shadow-sm">
<div className="animate-pulse">
<div className="h-6 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-2/3 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-32 mb-6"></div>
<div className="flex gap-3">
<div className="h-8 bg-gray-200 rounded w-20"></div>
<div className="h-8 bg-gray-200 rounded w-16"></div>
<div className="h-8 bg-gray-200 rounded w-16"></div>
</div>
</div>
</div>
</div>
)
}
export const Route = createFileRoute('/todos/$todoId')({
loader: async ({ params }) => {
try {
const todo = await api.getTodo(Number(params.todoId))
if (!todo) {
throw new Error(`Todo with ID ${params.todoId} not found`)
}
return todo
} catch (error) {
// Add context to the error
if (error instanceof Error) {
throw new Error(`Failed to load todo: ${error.message}`)
}
throw new Error(`Failed to load todo with ID ${params.todoId}`)
}
},
// Custom error handling for this route
errorComponent: LoadingError,
// Loading component
pendingComponent: TodoDetailPending,
// Loading timing configuration
pendingMs: 500, // Show loading after 500ms
pendingMinMs: 200, // Show loading for at least 200ms
// Cache configuration
staleTime: 30 * 1000, // Consider fresh for 30 seconds
// Error handling during route loading
onError: ({ error }) => {
console.error('Todo detail route error:', error)
// Could integrate with error tracking service here
},
component: TodoDetail,
})
Step 4: 404 Error Handling
Create a custom 404 route. Create src/routes/404.tsx:
import { createFileRoute } from '@tanstack/react-router'
import { NotFoundComponent } from '../components/ErrorComponents'
export const Route = createFileRoute('/404')({
component: NotFoundComponent,
})
Step 5: Error Boundary Testing
Add a test route to verify error handling. Create src/routes/error-test.tsx:
import { createFileRoute } from '@tanstack/react-router'
import { useState } from 'react'
function ErrorTest() {
const [shouldError, setShouldError] = useState(false)
if (shouldError) {
throw new Error('This is a test error to demonstrate error boundaries!')
}
return (
<div className="max-w-md mx-auto p-6">
<h2 className="text-xl font-semibold mb-4">Error Boundary Testing</h2>
<p className="text-gray-600 mb-6">
This page helps test the error handling capabilities of our Todo app.
</p>
<div className="space-y-4">
<button
onClick={() => setShouldError(true)}
className="w-full bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700"
>
Trigger Component Error
</button>
<button
onClick={() => {
// Trigger a loader error by navigating to non-existent todo
window.location.href = '/todos/99999'
}}
className="w-full bg-yellow-600 text-white px-4 py-2 rounded hover:bg-yellow-700"
>
Trigger Loader Error
</button>
<button
onClick={() => {
window.location.href = '/non-existent-route'
}}
className="w-full bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700"
>
Trigger 404 Error
</button>
</div>
</div>
)
}
export const Route = createFileRoute('/error-test')({
component: ErrorTest,
})
Error Handling Best Practices
- Graceful Degradation: Always provide fallback UI and recovery options
- User-Friendly Messages: Convert technical errors into understandable messages
- Error Tracking: Log errors for debugging while protecting user privacy
- Retry Mechanisms: Allow users to retry failed operations
- Context Preservation: Maintain navigation context even during errors
- Development vs Production: Show detailed errors in development, sanitized messages in production
This comprehensive error handling setup ensures your Todo app remains resilient and provides excellent user experience even when things go wrong.
Conclusion
Congratulations! You’ve successfully built a comprehensive Todo application using TanStack Router that demonstrates all the major features of this powerful routing library. Let’s recap what we’ve accomplished and explore next steps for further learning.
What We Built
Our Todo application showcases the full spectrum of TanStack Router capabilities:
Modern Routing Architecture: We implemented file-based routing with a clean, intuitive structure that maps directly to our application’s URL hierarchy.
Type-Safe Navigation: Every navigation action is fully typed, preventing routing errors at compile time and providing excellent developer experience with autocomplete and IntelliSense.
Advanced Data Loading: We utilized TanStack Router’s built-in loader system with SWR caching, search parameter validation, and intelligent preloading to create a fast, responsive user experience.
Robust Error Handling: Our application gracefully handles errors at multiple levels - from individual route failures to global error boundaries - ensuring users never encounter broken states.
Seamless State Management: By leveraging the router’s invalidation system, we kept cached data in perfect sync with mutations, eliminating the complexity typically associated with state management.
Key Features Demonstrated
- File-Based Routing with nested routes and layouts
- Type-Safe Parameters for route parameters and search queries
- Data Loading with caching, dependencies, and error handling
- Mutation Coordination with cache invalidation
- Programmatic Navigation with custom hooks and utilities
- Error Boundaries with recovery mechanisms
- Loading States with customizable pending components
- Search Parameter Management with validation and type safety
Performance Benefits Realized
- Zero Loading Flickers: Data loads before components render
- Intelligent Caching: Automatic SWR caching reduces redundant requests
- Code Splitting: Automatic route-based code splitting for optimal bundle sizes
- Preloading: Smart preloading based on user interaction patterns
- Structural Sharing: Minimized re-renders through intelligent state sharing
Next Steps for Learning
Explore Advanced Features:
- TanStack Start: Full-stack framework built on TanStack Router for SSR and API routes
- Route Context: Advanced patterns for sharing data between parent and child routes
- Virtual File Routes: Custom route generation for complex requirements
- Route Masking: Advanced URL rewriting capabilities
Integration Opportunities:
- TanStack Query: For more sophisticated server state management
- TanStack Form: Type-safe form handling with validation
- TanStack Table: Data grid components with routing integration
- Authentication: Implement protected routes and user sessions
Production Considerations:
- Error Monitoring: Integrate with services like Sentry for production error tracking
- Performance Monitoring: Add analytics and performance measurement
- SEO Optimization: Implement meta tags and structured data
- Accessibility: Enhance keyboard navigation and screen reader support
Community and Resources
Official Resources:
- TanStack Router Documentation
- GitHub Repository for issues and contributions
- Discord Community for questions and discussions
Learning Materials:
- TanStack Router Examples
- Comparison with Other Routers
- Migration Guides from React Router
Why Choose TanStack Router?
After building this Todo application, you’ve experienced firsthand why TanStack Router represents the future of React routing:
- Developer Experience: Type safety eliminates entire categories of bugs
- Performance: Built-in optimizations provide excellent user experience
- Modern Architecture: Designed for contemporary React development patterns
- Comprehensive Features: Everything you need in a single, cohesive package
- Active Development: Continuous innovation from the TanStack team
TanStack Router excels particularly well for:
- TypeScript-first applications where compile-time safety is crucial
- Complex applications with sophisticated data loading requirements
- Modern development teams comfortable with file-based routing patterns
- Performance-sensitive applications where every millisecond matters
Final Thoughts
TanStack Router represents a significant evolution in React routing, combining the best ideas from frameworks like Next.js and Remix while maintaining the flexibility and control that React developers love. The type safety, performance optimizations, and developer experience improvements make it an excellent choice for modern React applications.
Your Todo application serves as a solid foundation for building more complex applications. The patterns and techniques you’ve learned - from route organization to error handling to state management - will scale effectively as your applications grow in complexity.
Download the source code here: https://github.com/rtome85/todo-app-tanstack
Tags:
Related Posts
Design Systems 101: A Beginner's Guide to Building Consistency from Scratch
Learn how to build a design system from scratch and create consistent, professional interfaces. This beginner-friendly guide covers everything from color palettes and typography to components and documentation.
Complete React Testing with Vitest: A Beginner-Friendly Tutorial
Master React testing with Vitest in this comprehensive beginner's guide. Learn to test components, hooks, async operations, and implement mocking strategies with practical examples and best practices.