references/anti-patterns.md
# react-impl-routing -- Anti-Patterns
## AP-01: Using Legacy BrowserRouter for New Projects
**NEVER** use `<BrowserRouter>` with `<Routes>` and `<Route>` in new projects.
```tsx
// WRONG -- legacy pattern, no loader/action support
import { BrowserRouter, Routes, Route } from "react-router-dom";
function App(): React.ReactElement {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
</Routes>
</BrowserRouter>
);
}
```
```tsx
// CORRECT -- data router with full feature support
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const router = createBrowserRouter([
{ path: "/", element: <Home /> },
{ path: "/about", element: <About /> },
]);
function App(): React.ReactElement {
return <RouterProvider router={router} />;
}
```
**Why**: The legacy API cannot use `loader`, `action`, `lazy`, `defer`, `useFetcher`, or `useNavigation`. These are the core features that make React Router v6.4+ powerful.
---
## AP-02: Defining Router Inside a Component
**NEVER** create the router object inside a React component.
```tsx
// WRONG -- router recreated every render, destroys all state
function App(): React.ReactElement {
const router = createBrowserRouter([
{ path: "/", element: <Home /> },
]);
return <RouterProvider router={router} />;
}
```
```tsx
// CORRECT -- router defined at module scope
const router = createBrowserRouter([
{ path: "/", element: <Home /> },
]);
function App(): React.ReactElement {
return <RouterProvider router={router} />;
}
```
**Why**: `createBrowserRouter` creates a stateful router instance. Recreating it on every render resets all navigation state, loader caches, and pending navigations.
---
## AP-03: Calling navigate() During Render
**NEVER** call `navigate()` in the component body or during render.
```tsx
// WRONG -- causes infinite re-render loop
function Dashboard(): React.ReactElement {
const navigate = useNavigate();
const { user } = useAuth();
if (!user) {
navigate("/login"); // called during render!
}
return <div>Dashboard</div>;
}
```
```tsx
// CORRECT -- use Navigate component for render-time redirects
function Dashboard(): React.ReactElement {
const { user } = useAuth();
if (!user) {
return <Navigate to="/login" replace />;
}
return <div>Dashboard</div>;
}
// ALSO CORRECT -- use useEffect for side-effect navigation
function Dashboard(): React.ReactElement {
const navigate = useNavigate();
const { user } = useAuth();
useEffect(() => {
if (!user) navigate("/login", { replace: true });
}, [user, navigate]);
return <div>Dashboard</div>;
}
// BEST -- use loader-based redirect (no component render needed)
function dashboardLoader(): null | Response {
if (!checkAuth()) return redirect("/login");
return null;
}
```
**Why**: `navigate()` triggers a state update. Calling it during render causes React to re-render, which calls navigate again, creating an infinite loop.
---
## AP-04: Using useEffect for Data Fetching Instead of Loaders
**NEVER** fetch route data in `useEffect` when a loader is available.
```tsx
// WRONG -- waterfall: render component, then fetch, then render again
function ProjectDetail(): React.ReactElement {
const { id } = useParams<{ id: string }>();
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/projects/${id}`)
.then((r) => r.json())
.then(setProject)
.finally(() => setLoading(false));
}, [id]);
if (loading) return <Spinner />;
if (!project) return <NotFound />;
return <div>{project.name}</div>;
}
```
```tsx
// CORRECT -- loader fetches before render, no loading state needed
async function loader({ params }: LoaderFunctionArgs): Promise<Project> {
const response = await fetch(`/api/projects/${params.id}`);
if (!response.ok) throw new Response("Not found", { status: 404 });
return response.json();
}
function ProjectDetail(): React.ReactElement {
const project = useLoaderData() as Project;
return <div>{project.name}</div>;
}
```
**Why**: Loaders run in parallel with route transitions, eliminating render-fetch waterfalls. The component receives data already loaded, removing the need for loading states and null checks.
---
## AP-05: Using Native form Instead of Router Form
**NEVER** use `<form>` when you want the router to handle submission.
```tsx
// WRONG -- bypasses router, triggers full page navigation
function CreateProject(): React.ReactElement {
return (
<form method="post" action="/projects/new">
<input name="name" />
<button type="submit">Create</button>
</form>
);
}
```
```tsx
// CORRECT -- router intercepts, calls route action, stays client-side
import { Form } from "react-router-dom";
function CreateProject(): React.ReactElement {
return (
<Form method="post">
<input name="name" />
<button type="submit">Create</button>
</Form>
);
}
```
**Why**: Native `<form>` causes a full-page POST request. React Router's `<Form>` intercepts the submission, serializes form data into a `Request`, and passes it to the route's `action` function -- all client-side.
---
## AP-06: Returning undefined from Loaders or Actions
**NEVER** forget to return a value from a loader or action.
```tsx
// WRONG -- returns undefined, causes runtime error
async function loader({ params }: LoaderFunctionArgs) {
const response = await fetch(`/api/items/${params.id}`);
if (response.ok) {
return response.json();
}
// falls through to undefined when not ok!
}
```
```tsx
// CORRECT -- ALWAYS return or throw
async function loader({ params }: LoaderFunctionArgs): Promise<Item> {
const response = await fetch(`/api/items/${params.id}`);
if (!response.ok) {
throw new Response("Not found", { status: 404 });
}
return response.json();
}
```
**Why**: React Router expects loaders and actions to return a value or throw. Returning `undefined` causes "Cannot read properties of undefined" errors when `useLoaderData()` or `useActionData()` attempts to access the result.
---
## AP-07: Not Handling Errors with errorElement
**NEVER** leave routes without error boundaries.
```tsx
// WRONG -- error in loader crashes entire app
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
children: [
{ path: "projects/:id", element: <Project />, loader: projectLoader },
],
},
]);
```
```tsx
// CORRECT -- root errorElement catches unhandled errors,
// child errorElement provides granular recovery
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <RootError />, // catch-all
children: [
{
path: "projects/:id",
element: <Project />,
loader: projectLoader,
errorElement: <ProjectError />, // route-specific
},
],
},
]);
```
**Why**: Without `errorElement`, an error in a loader, action, or component render bubbles up and crashes the entire application. ALWAYS place an `errorElement` on the root route at minimum.
---
## AP-08: Using Lazy with Default Export
**NEVER** use `default` export in lazy route modules.
```tsx
// WRONG -- lazy() does not use default export
// routes/admin.tsx
export default function Admin() { return <div>Admin</div>; }
// router config
{ path: "admin", lazy: () => import("./routes/admin") }
// Result: nothing renders because there is no `Component` export
```
```tsx
// CORRECT -- use named exports matching RouteObject properties
// routes/admin.tsx
export function Component() { return <div>Admin</div>; }
export async function loader() { return fetchAdminData(); }
// router config
{ path: "admin", lazy: () => import("./routes/admin") }
```
**Why**: `lazy()` spreads the returned module's named exports onto the route object. It looks for `Component`, `loader`, `action`, `ErrorBoundary`, etc. A `default` export is ignored entirely.
---
## AP-09: Accessing Component State in Loaders
**NEVER** try to access React state, context, or hooks in loaders/actions.
```tsx
// WRONG -- loaders run outside React, cannot use hooks or context
async function loader(): Promise<Data> {
const { token } = useAuth(); // ERROR: hooks only work in components
return fetch("/api/data", { headers: { Authorization: token } });
}
```
```tsx
// CORRECT -- use non-React state management in loaders
async function loader({ request }: LoaderFunctionArgs): Promise<Data> {
const token = getTokenFromCookie(); // plain function, no React
if (!token) return redirect("/login");
const response = await fetch("/api/data", {
headers: { Authorization: `Bearer ${token}` },
});
return response.json();
}
```
**Why**: Loaders and actions execute outside the React component tree. They have no access to React hooks, context, or component state. Use plain JavaScript functions, cookies, localStorage, or module-level singletons for data that loaders need.
---
## AP-10: Mutating searchParams Directly
**NEVER** mutate the `URLSearchParams` object from `useSearchParams` without using the setter.
```tsx
// WRONG -- mutating the object does not trigger navigation
function FilterBar(): React.ReactElement {
const [searchParams] = useSearchParams();
const handleFilter = (value: string): void => {
searchParams.set("filter", value); // mutates but does NOT update URL
};
return <button onClick={() => handleFilter("active")}>Active</button>;
}
```
```tsx
// CORRECT -- use the setter function
function FilterBar(): React.ReactElement {
const [searchParams, setSearchParams] = useSearchParams();
const handleFilter = (value: string): void => {
setSearchParams((prev) => {
prev.set("filter", value);
return prev;
});
};
return <button onClick={() => handleFilter("active")}>Active</button>;
}
```
**Why**: `URLSearchParams` is a mutable object, but React Router only updates the URL when `setSearchParams` is called. Directly mutating the object silently does nothing visible.
references/api-table.md
# react-impl-routing -- API Reference
## Router Creation Functions
| Function | Signature | Purpose |
|----------|-----------|---------|
| `createBrowserRouter` | `(routes: RouteObject[], opts?: { basename?: string }) => Router` | Create router using History API (production standard) |
| `createHashRouter` | `(routes: RouteObject[], opts?: { basename?: string }) => Router` | Create router using hash URLs (legacy/static hosting) |
| `createMemoryRouter` | `(routes: RouteObject[], opts?: { initialEntries?: string[], initialIndex?: number }) => Router` | Create in-memory router (testing, non-browser) |
---
## Router Components
| Component | Props | Purpose |
|-----------|-------|---------|
| `<RouterProvider>` | `router: Router`, `fallbackElement?: ReactElement` | Mount a data router in React tree |
| `<Outlet>` | `context?: unknown` | Render matched child route element |
| `<Link>` | `to: string \| Partial<Path>`, `replace?: boolean`, `state?: any` | Client-side navigation link |
| `<NavLink>` | Same as Link + `className?: string \| (props: { isActive, isPending }) => string`, `style?: ...`, `end?: boolean` | Navigation link with active state |
| `<Navigate>` | `to: string`, `replace?: boolean`, `state?: any` | Declarative redirect (renders nothing) |
| `<Form>` | `method?: string`, `action?: string`, `encType?: string`, `replace?: boolean` | Form that triggers route action |
| `<ScrollRestoration>` | `getKey?: (location, matches) => string` | Restore scroll position on navigation |
| `<Await>` | `resolve: Promise<T>`, `errorElement?: ReactElement`, `children: (data: T) => ReactElement` | Render deferred data inside Suspense |
---
## Hooks -- Navigation
| Hook | Signature | Purpose |
|------|-----------|---------|
| `useNavigate` | `() => NavigateFunction` | Programmatic navigation |
| `useLocation` | `() => Location` | Current location object `{ pathname, search, hash, state, key }` |
| `useHref` | `(to: string) => string` | Resolve a relative path to absolute href |
| `useResolvedPath` | `(to: string) => Path` | Resolve relative path to `{ pathname, search, hash }` |
| `useNavigation` | `() => Navigation` | Navigation state: `{ state: "idle" \| "loading" \| "submitting", formData, location }` |
| `useRevalidator` | `() => { revalidate(), state }` | Manually trigger loader revalidation |
### NavigateFunction
```tsx
type NavigateFunction = {
(to: string, options?: { replace?: boolean; state?: any }): void;
(delta: number): void; // navigate(-1) = back, navigate(1) = forward
};
```
---
## Hooks -- Route Data
| Hook | Signature | Purpose |
|------|-----------|---------|
| `useParams` | `<T extends Record<string, string \| undefined>>() => T` | Read URL dynamic segments |
| `useSearchParams` | `() => [URLSearchParams, SetURLSearchParams]` | Read/write URL query string |
| `useLoaderData` | `() => unknown` | Access current route loader return value |
| `useActionData` | `() => unknown` | Access current route action return value |
| `useRouteLoaderData` | `(routeId: string) => unknown` | Access loader data from a parent route by ID |
| `useMatches` | `() => UIMatch[]` | All currently matched routes with data and handle |
| `useRouteError` | `() => unknown` | Access error thrown in loader/action/render |
| `useFetcher` | `<T>() => Fetcher<T>` | Call loaders/actions without navigation |
| `useFetchers` | `() => Fetcher[]` | All active fetchers |
### SetURLSearchParams
```tsx
type SetURLSearchParams = (
nextInit: URLSearchParams | ((prev: URLSearchParams) => URLSearchParams),
navigateOpts?: { replace?: boolean; state?: any }
) => void;
```
---
## Hooks -- Form Handling
| Hook | Signature | Purpose |
|------|-----------|---------|
| `useSubmit` | `() => SubmitFunction` | Programmatically submit a form |
| `useFormAction` | `(action?: string) => string` | Resolve form action URL |
### SubmitFunction
```tsx
type SubmitFunction = (
target: FormData | URLSearchParams | Record<string, string> | null,
options?: { method?: string; action?: string; encType?: string; replace?: boolean }
) => void;
```
---
## RouteObject Properties
| Property | Type | Description |
|----------|------|-------------|
| `path` | `string` | URL path segment. Supports `:param` (dynamic) and `:param?` (optional) |
| `index` | `boolean` | Marks as index route (default child). Mutually exclusive with `children` |
| `element` | `ReactElement` | Component to render when route matches |
| `Component` | `React.ComponentType` | Alternative to element -- pass component reference |
| `errorElement` | `ReactElement` | UI to render when loader/action/render throws |
| `ErrorBoundary` | `React.ComponentType` | Alternative to errorElement -- pass component reference |
| `loader` | `(args: LoaderFunctionArgs) => Promise<T> \| T` | Data loading function |
| `action` | `(args: ActionFunctionArgs) => Promise<T> \| T` | Mutation handling function |
| `lazy` | `() => Promise<Partial<RouteObject>>` | Code-split route module |
| `children` | `RouteObject[]` | Nested child routes |
| `handle` | `unknown` | Custom data accessible via `useMatches()` |
| `shouldRevalidate` | `(args: ShouldRevalidateFunctionArgs) => boolean` | Control when loaders re-run |
| `id` | `string` | Unique route identifier (auto-generated if omitted) |
---
## LoaderFunctionArgs / ActionFunctionArgs
| Property | Type | Description |
|----------|------|-------------|
| `params` | `Record<string, string>` | URL dynamic segment values |
| `request` | `Request` | Standard Fetch API Request object with URL, method, headers |
### Accessing query parameters in a loader:
```tsx
async function loader({ request }: LoaderFunctionArgs): Promise<Data> {
const url = new URL(request.url);
const query = url.searchParams.get("q");
// ...
}
```
### Accessing form data in an action:
```tsx
async function action({ request, params }: ActionFunctionArgs): Promise<Response> {
const formData = await request.formData();
const name = formData.get("name") as string;
// ...
}
```
---
## Utility Functions
| Function | Signature | Purpose |
|----------|-----------|---------|
| `redirect` | `(url: string, init?: number \| ResponseInit) => Response` | Return redirect response from loader/action |
| `json` | `(data: T, init?: ResponseInit) => Response` | Return JSON response (deprecated in v7, use plain return) |
| `defer` | `(data: Record<string, unknown>) => DeferredData` | Return mix of awaited and deferred promises |
| `isRouteErrorResponse` | `(error: unknown) => error is ErrorResponse` | Type guard for Response-based errors |
| `generatePath` | `(path: string, params: Record<string, string>) => string` | Fill dynamic segments in path template |
| `matchPath` | `(pattern: string, pathname: string) => PathMatch \| null` | Test if pathname matches a pattern |
| `matchRoutes` | `(routes: RouteObject[], location: string) => RouteMatch[] \| null` | Match routes against a location |
---
## Fetcher API
`useFetcher()` returns a fetcher that can call loaders/actions without causing navigation:
| Property / Method | Type | Description |
|-------------------|------|-------------|
| `fetcher.load(href)` | `(href: string) => void` | Call a route loader |
| `fetcher.submit(data, opts)` | `SubmitFunction` | Call a route action |
| `fetcher.data` | `T \| undefined` | Last returned data |
| `fetcher.state` | `"idle" \| "loading" \| "submitting"` | Current fetcher state |
| `fetcher.formData` | `FormData \| undefined` | Form data being submitted |
| `fetcher.Form` | `React.ComponentType` | Form component bound to this fetcher |
```tsx
function InlineDelete({ projectId }: { projectId: string }): React.ReactElement {
const fetcher = useFetcher();
const isDeleting = fetcher.state !== "idle";
return (
<fetcher.Form method="post" action={`/projects/${projectId}`}>
<input type="hidden" name="intent" value="delete" />
<button type="submit" disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</button>
</fetcher.Form>
);
}
```
---
## Official Sources
- https://reactrouter.com/en/main/start/overview
- https://reactrouter.com/en/main/routers/create-browser-router
- https://reactrouter.com/en/main/route/route
- https://reactrouter.com/en/main/route/loader
- https://reactrouter.com/en/main/route/action
- https://reactrouter.com/en/main/route/lazy
references/examples.md
# react-impl-routing -- Examples
## Full Application Router Setup
```tsx
// src/router.tsx
import { createBrowserRouter } from "react-router-dom";
import { RootLayout } from "./layouts/RootLayout";
import { RootError } from "./errors/RootError";
import { HomePage } from "./pages/HomePage";
export const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
errorElement: <RootError />,
children: [
{ index: true, element: <HomePage /> },
{
path: "projects",
lazy: () => import("./routes/projects"),
},
{
path: "projects/:projectId",
lazy: () => import("./routes/project-detail"),
},
{
path: "projects/new",
lazy: () => import("./routes/project-new"),
},
{
path: "admin",
lazy: () => import("./routes/admin"),
},
{
path: "login",
lazy: () => import("./routes/login"),
},
],
},
]);
// src/main.tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { router } from "./router";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>
);
```
---
## Lazy Route Module
ALWAYS export named properties that map to `RouteObject` fields:
```tsx
// src/routes/projects.tsx
import { useLoaderData, type LoaderFunctionArgs } from "react-router-dom";
interface Project {
id: string;
name: string;
status: "active" | "archived";
}
export async function loader({ request }: LoaderFunctionArgs): Promise<Project[]> {
const url = new URL(request.url);
const status = url.searchParams.get("status") ?? "active";
const response = await fetch(`/api/projects?status=${status}`);
if (!response.ok) {
throw new Response("Failed to load projects", { status: response.status });
}
return response.json();
}
export function Component(): React.ReactElement {
const projects = useLoaderData() as Project[];
return (
<div>
<h1>Projects</h1>
<ul>
{projects.map((project) => (
<li key={project.id}>
<Link to={project.id}>{project.name}</Link>
</li>
))}
</ul>
</div>
);
}
Component.displayName = "ProjectList";
```
---
## CRUD Route with Loader + Action
```tsx
// src/routes/project-detail.tsx
import {
useLoaderData,
useActionData,
Form,
redirect,
type LoaderFunctionArgs,
type ActionFunctionArgs,
} from "react-router-dom";
interface Project {
id: string;
name: string;
description: string;
}
interface ActionResult {
errors?: { name?: string; description?: string };
}
export async function loader({ params }: LoaderFunctionArgs): Promise<Project> {
const response = await fetch(`/api/projects/${params.projectId}`);
if (!response.ok) {
throw new Response("Project not found", { status: 404 });
}
return response.json();
}
export async function action({
params,
request,
}: ActionFunctionArgs): Promise<ActionResult | Response> {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "delete") {
await fetch(`/api/projects/${params.projectId}`, { method: "DELETE" });
return redirect("/projects");
}
const name = formData.get("name") as string;
const description = formData.get("description") as string;
const errors: ActionResult["errors"] = {};
if (!name || name.length < 2) errors.name = "Name must be at least 2 characters";
if (Object.keys(errors).length > 0) return { errors };
await fetch(`/api/projects/${params.projectId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, description }),
});
return redirect("/projects");
}
export function Component(): React.ReactElement {
const project = useLoaderData() as Project;
const actionData = useActionData() as ActionResult | undefined;
return (
<div>
<h1>Edit: {project.name}</h1>
<Form method="post">
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" defaultValue={project.name} />
{actionData?.errors?.name && (
<p className="error">{actionData.errors.name}</p>
)}
</div>
<div>
<label htmlFor="description">Description</label>
<textarea
id="description"
name="description"
defaultValue={project.description}
/>
</div>
<button type="submit">Save</button>
</Form>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<button type="submit" className="danger">Delete</button>
</Form>
</div>
);
}
Component.displayName = "ProjectDetail";
```
---
## Protected Route with Auth Redirect
```tsx
// src/routes/admin.tsx
import {
redirect,
useLoaderData,
type LoaderFunctionArgs,
} from "react-router-dom";
interface AdminData {
users: Array<{ id: string; email: string; role: string }>;
stats: { totalUsers: number; activeToday: number };
}
export async function loader({ request }: LoaderFunctionArgs): Promise<AdminData | Response> {
const token = getAuthToken();
if (!token) {
const url = new URL(request.url);
return redirect(`/login?returnTo=${url.pathname}`);
}
const response = await fetch("/api/admin/dashboard", {
headers: { Authorization: `Bearer ${token}` },
});
if (response.status === 403) {
throw new Response("Forbidden", { status: 403 });
}
if (!response.ok) {
throw new Response("Failed to load admin data", { status: response.status });
}
return response.json();
}
export function Component(): React.ReactElement {
const { users, stats } = useLoaderData() as AdminData;
return (
<div>
<h1>Admin Dashboard</h1>
<div className="stats">
<p>Total users: {stats.totalUsers}</p>
<p>Active today: {stats.activeToday}</p>
</div>
<table>
<thead>
<tr><th>Email</th><th>Role</th></tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>{user.email}</td>
<td>{user.role}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
Component.displayName = "AdminDashboard";
```
---
## Deferred Loading with Suspense
```tsx
// src/routes/dashboard.tsx
import { Suspense } from "react";
import {
defer,
Await,
useLoaderData,
type LoaderFunctionArgs,
} from "react-router-dom";
interface User {
id: string;
name: string;
}
interface Activity {
id: string;
message: string;
timestamp: string;
}
interface Notification {
id: string;
title: string;
read: boolean;
}
interface DashboardData {
user: User;
recentActivity: Promise<Activity[]>;
notifications: Promise<Notification[]>;
}
export async function loader({ request }: LoaderFunctionArgs) {
const user = await fetchUser(); // critical -- await immediately
return defer({
user,
recentActivity: fetchActivity(user.id), // deferred
notifications: fetchNotifications(user.id), // deferred
});
}
export function Component(): React.ReactElement {
const { user, recentActivity, notifications } = useLoaderData() as DashboardData;
return (
<div>
<h1>Welcome, {user.name}</h1>
<section>
<h2>Recent Activity</h2>
<Suspense fallback={<p>Loading activity...</p>}>
<Await
resolve={recentActivity}
errorElement={<p>Failed to load activity</p>}
>
{(activities: Activity[]) => (
<ul>
{activities.map((a) => (
<li key={a.id}>{a.message}</li>
))}
</ul>
)}
</Await>
</Suspense>
</section>
<section>
<h2>Notifications</h2>
<Suspense fallback={<p>Loading notifications...</p>}>
<Await
resolve={notifications}
errorElement={<p>Failed to load notifications</p>}
>
{(items: Notification[]) => (
<ul>
{items.map((n) => (
<li key={n.id} className={n.read ? "read" : "unread"}>
{n.title}
</li>
))}
</ul>
)}
</Await>
</Suspense>
</section>
</div>
);
}
Component.displayName = "Dashboard";
```
---
## Search Parameters with Pagination
```tsx
import { useSearchParams, Link } from "react-router-dom";
interface PaginationProps {
totalPages: number;
}
function Pagination({ totalPages }: PaginationProps): React.ReactElement {
const [searchParams] = useSearchParams();
const currentPage = Number(searchParams.get("page") ?? "1");
const createPageUrl = (page: number): string => {
const params = new URLSearchParams(searchParams);
params.set("page", String(page));
return `?${params.toString()}`;
};
return (
<nav aria-label="Pagination">
{currentPage > 1 && (
<Link to={createPageUrl(currentPage - 1)}>Previous</Link>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<Link
key={page}
to={createPageUrl(page)}
className={page === currentPage ? "active" : ""}
aria-current={page === currentPage ? "page" : undefined}
>
{page}
</Link>
))}
{currentPage < totalPages && (
<Link to={createPageUrl(currentPage + 1)}>Next</Link>
)}
</nav>
);
}
```
---
## Nested Layout with Breadcrumbs
```tsx
import { Outlet, useMatches, Link } from "react-router-dom";
interface RouteHandle {
breadcrumb: string;
}
function BreadcrumbLayout(): React.ReactElement {
const matches = useMatches();
const breadcrumbs = matches
.filter((match) => (match.handle as RouteHandle)?.breadcrumb)
.map((match) => ({
path: match.pathname,
label: (match.handle as RouteHandle).breadcrumb,
}));
return (
<div>
<nav aria-label="Breadcrumb">
<ol>
{breadcrumbs.map((crumb, index) => (
<li key={crumb.path}>
{index < breadcrumbs.length - 1 ? (
<Link to={crumb.path}>{crumb.label}</Link>
) : (
<span aria-current="page">{crumb.label}</span>
)}
</li>
))}
</ol>
</nav>
<Outlet />
</div>
);
}
// Route config with handle for breadcrumbs:
// {
// path: "projects",
// element: <BreadcrumbLayout />,
// handle: { breadcrumb: "Projects" },
// children: [
// { index: true, element: <ProjectList />, handle: { breadcrumb: "All" } },
// { path: ":id", element: <ProjectDetail />, handle: { breadcrumb: "Detail" } },
// ],
// }
```
SKILL.md
---
name: react-impl-routing
description: >
Use when implementing client-side routing, setting up route configuration,
handling navigation, or building protected routes. Prevents the common mistake
of using legacy BrowserRouter instead of the data router API. Covers React
Router v6+ createBrowserRouter, nested routes, Outlet, data loaders, actions,
lazy routes, URL params, search params.
Keywords: React Router, createBrowserRouter, Outlet, loader, action, route, protected route, login redirect, auth guard, private page, page navigation, nested routes..
license: MIT
compatibility: "Designed for Claude Code. Requires React 18.x or 19.x with React Router 6.4+."
metadata:
author: OpenAEC-Foundation
version: "1.0"
---
# react-impl-routing
## Quick Reference
### Router Setup (React Router 6.4+)
| Concept | API | Purpose |
|---------|-----|---------|
| Router creation | `createBrowserRouter()` | Define route tree as object array |
| Router rendering | `<RouterProvider router={router} />` | Mount the data router in React |
| Nested rendering | `<Outlet />` | Render child route element inside parent layout |
| Navigation link | `<Link to="/path">` | Client-side navigation without reload |
| Active link | `<NavLink className={({isActive}) => ...}>` | Link with active state styling |
| Programmatic nav | `useNavigate()` | Navigate from event handlers or effects |
| URL params | `useParams()` | Read dynamic route segments |
| Query strings | `useSearchParams()` | Read and update URL search parameters |
| Loader data | `useLoaderData()` | Access data returned by route loader |
| Action data | `useActionData()` | Access data returned by route action |
| Error info | `useRouteError()` | Access error thrown in loader/action/render |
### Critical Warnings
**NEVER** use the legacy `<BrowserRouter>` + `<Routes>` pattern for new projects -- ALWAYS use `createBrowserRouter` with `<RouterProvider>`. The data router API enables loaders, actions, and lazy routes that the legacy API cannot support.
**NEVER** call `navigate()` during render -- ALWAYS call it inside `useEffect`, event handlers, or loader/action functions. Calling during render causes infinite re-render loops.
**NEVER** define route objects inside a component -- ALWAYS define routes at module scope or in a separate file. Defining inside a component recreates the router on every render, destroying all state.
**NEVER** use `loader` or `action` as async arrow functions that capture component scope -- loaders and actions run outside React component lifecycle. They receive `{params, request}` as arguments.
**ALWAYS** return or throw a `Response` or value from loaders and actions -- returning `undefined` causes runtime errors.
**ALWAYS** use `<Form>` from `react-router-dom` instead of `<form>` when you want route actions to handle submission -- native `<form>` bypasses the router entirely.
---
## Route Configuration
### createBrowserRouter Pattern
ALWAYS define routes as a configuration object array:
```tsx
import {
createBrowserRouter,
RouterProvider,
} from "react-router-dom";
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
errorElement: <RootError />,
children: [
{ index: true, element: <HomePage /> },
{
path: "projects",
element: <ProjectsLayout />,
children: [
{ index: true, element: <ProjectList /> },
{
path: ":projectId",
element: <ProjectDetail />,
loader: projectLoader,
action: projectAction,
errorElement: <ProjectError />,
},
],
},
{
path: "settings",
lazy: () => import("./routes/settings"),
},
],
},
]);
function App(): React.ReactElement {
return <RouterProvider router={router} />;
}
```
### Route Properties
| Property | Type | Purpose |
|----------|------|---------|
| `path` | `string` | URL segment to match |
| `element` | `ReactElement` | Component to render when matched |
| `errorElement` | `ReactElement` | Fallback UI when loader/action/render throws |
| `loader` | `LoaderFunction` | Fetch data before rendering |
| `action` | `ActionFunction` | Handle form submissions / mutations |
| `lazy` | `() => Promise<RouteObject>` | Code-split route module |
| `children` | `RouteObject[]` | Nested child routes |
| `index` | `boolean` | Default child route (renders in parent Outlet) |
---
## Nested Routes and Layouts
Use `<Outlet />` in parent routes to render matched child routes:
```tsx
import { Outlet, NavLink } from "react-router-dom";
function RootLayout(): React.ReactElement {
return (
<div>
<nav>
<NavLink
to="/"
className={({ isActive }) => (isActive ? "active" : "")}
>
Home
</NavLink>
<NavLink
to="/projects"
className={({ isActive }) => (isActive ? "active" : "")}
>
Projects
</NavLink>
</nav>
<main>
<Outlet />
</main>
</div>
);
}
```
### Layout Route (pathless)
A route without a `path` serves as a layout wrapper without adding a URL segment:
```tsx
{
element: <AuthenticatedLayout />,
children: [
{ path: "dashboard", element: <Dashboard /> },
{ path: "profile", element: <Profile /> },
],
}
```
### Index Route
An index route is the default child that renders when the parent path matches exactly:
```tsx
{
path: "projects",
element: <ProjectsLayout />,
children: [
{ index: true, element: <ProjectList /> }, // matches /projects
{ path: ":id", element: <ProjectDetail /> }, // matches /projects/123
],
}
```
---
## Navigation
### Link and NavLink
```tsx
import { Link, NavLink } from "react-router-dom";
<Link to="/projects">Projects</Link>
<Link to="../settings">Settings</Link> {/* relative to current route */}
<NavLink
to="/projects"
className={({ isActive, isPending }) =>
isPending ? "pending" : isActive ? "active" : ""
}
>
Projects
</NavLink>
```
### useNavigate
```tsx
const navigate = useNavigate();
// In event handler or effect -- NEVER during render
navigate(`/projects/${id}`);
navigate("/login", { replace: true }); // replace history entry
navigate(-1); // go back
navigate("/dash", { state: { from: "/" } }); // pass state
```
---
## URL Parameters and Search Parameters
### useParams
```tsx
import { useParams } from "react-router-dom";
// Route: { path: "projects/:projectId/tasks/:taskId?" }
function TaskView(): React.ReactElement {
const { projectId, taskId } = useParams<{
projectId: string;
taskId?: string; // optional segment marked with ?
}>();
// ALWAYS check params exist -- useParams returns string | undefined
if (!projectId) throw new Error("projectId is required");
return <div>Project: {projectId}, Task: {taskId ?? "none"}</div>;
}
```
### useSearchParams
```tsx
function ProjectList(): React.ReactElement {
const [searchParams, setSearchParams] = useSearchParams();
const filter = searchParams.get("filter") ?? "all";
const page = Number(searchParams.get("page") ?? "1");
const updateFilter = (newFilter: string): void => {
setSearchParams((prev) => {
prev.set("filter", newFilter);
prev.set("page", "1");
return prev;
});
};
return (
<div>
<button onClick={() => updateFilter("active")}>Active</button>
<button onClick={() => updateFilter("all")}>All</button>
<p>Filter: {filter}, Page: {page}</p>
</div>
);
}
```
---
## Data Loading
### Route Loader
Loaders receive `{params, request}` and run before the route renders:
```tsx
import { useLoaderData, type LoaderFunctionArgs } from "react-router-dom";
interface Project { id: string; name: string }
async function projectLoader({ params, request }: LoaderFunctionArgs): Promise<Project> {
const response = await fetch(`/api/projects/${params.projectId}`);
if (!response.ok) throw new Response("Project not found", { status: 404 });
return response.json();
}
function ProjectDetail(): React.ReactElement {
const project = useLoaderData() as Project;
return <h1>{project.name}</h1>;
}
// Register: { path: ":projectId", element: <ProjectDetail />, loader: projectLoader }
```
### Deferred Data with defer + Await
Use `defer()` to return a mix of awaited (critical) and deferred (non-critical) data. Render deferred promises with `<Suspense>` + `<Await>`:
```tsx
export async function loader(): Promise<ReturnType<typeof defer>> {
const user = await fetchUser(); // critical -- await immediately
return defer({
user,
recommendations: fetchRecommendations(), // deferred -- NOT awaited
});
}
export function Component(): React.ReactElement {
const { user, recommendations } = useLoaderData() as {
user: User;
recommendations: Promise<Recommendation[]>;
};
return (
<div>
<h1>Welcome {user.name}</h1>
<Suspense fallback={<p>Loading...</p>}>
<Await resolve={recommendations}>
{(data: Recommendation[]) => <RecommendationList items={data} />}
</Await>
</Suspense>
</div>
);
}
```
See [references/examples.md](references/examples.md) for a full deferred loading example.
---
## Route Actions
### Form and Action Pattern
ALWAYS use `<Form>` from `react-router-dom` (not native `<form>`) to trigger route actions:
```tsx
import { Form, useActionData, redirect, type ActionFunctionArgs } from "react-router-dom";
interface ActionErrors { name?: string }
async function createAction({ request }: ActionFunctionArgs): Promise<ActionErrors | Response> {
const formData = await request.formData();
const name = formData.get("name") as string;
if (!name || name.length < 3) return { name: "Name must be at least 3 characters" };
const project = await createProject({ name });
return redirect(`/projects/${project.id}`);
}
function NewProject(): React.ReactElement {
const errors = useActionData() as ActionErrors | undefined;
return (
<Form method="post">
<input name="name" type="text" />
{errors?.name && <span className="error">{errors.name}</span>}
<button type="submit">Create</button>
</Form>
);
}
// Register: { path: "new", element: <NewProject />, action: createAction }
```
---
## Lazy Routes
ALWAYS use `lazy()` for route-level code splitting on routes not needed at initial load:
```tsx
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
children: [
{ index: true, element: <HomePage /> },
{
path: "admin",
lazy: () => import("./routes/admin"),
},
],
},
]);
// ./routes/admin.tsx -- MUST export named properties matching RouteObject
export async function loader(): Promise<AdminData> {
return fetchAdminData();
}
export function Component(): React.ReactElement {
const data = useLoaderData() as AdminData;
return <AdminPanel data={data} />;
}
// Optional: export errorElement, action, etc.
```
The `lazy()` function MUST return an object with route properties (`Component`, `loader`, `action`, `errorElement`). It NEVER returns a `default` export -- use named exports matching the route property names.
---
## Protected Routes
### Loader-Based Protection (Preferred)
```tsx
import { redirect, type LoaderFunctionArgs } from "react-router-dom";
function protectedLoader({ request }: LoaderFunctionArgs): null | Response {
const isAuthenticated = checkAuth();
if (!isAuthenticated) {
const url = new URL(request.url);
return redirect(`/login?returnTo=${url.pathname}`);
}
return null;
}
// Apply to route:
// { path: "dashboard", element: <Dashboard />, loader: protectedLoader }
```
### Wrapper Component Pattern
```tsx
import { Navigate, Outlet, useLocation } from "react-router-dom";
function RequireAuth(): React.ReactElement {
const { user } = useAuth();
const location = useLocation();
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
}
// Use as layout route:
// {
// element: <RequireAuth />,
// children: [
// { path: "dashboard", element: <Dashboard /> },
// { path: "settings", element: <Settings /> },
// ],
// }
```
---
## Error Handling
### errorElement and useRouteError
```tsx
import { useRouteError, isRouteErrorResponse } from "react-router-dom";
function RouteError(): React.ReactElement {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return <div><h1>{error.status}</h1><p>{error.statusText}</p></div>;
}
return <div><h1>Error</h1><p>{error instanceof Error ? error.message : "Unknown"}</p></div>;
}
// ALWAYS place errorElement on the root route as a catch-all.
// Place specific errorElement on child routes for granular error UIs.
```
---
## Decision Trees
### Which Router Pattern?
```
New project?
├── YES → createBrowserRouter + RouterProvider (ALWAYS)
└── NO (legacy codebase with BrowserRouter)
├── Can migrate? → YES → migrate to createBrowserRouter
└── Cannot migrate yet → keep BrowserRouter, but do NOT add loaders/actions
```
### Where to Put Auth Check?
```
Need to redirect before any rendering?
├── YES → Use loader-based protection (redirect in loader)
└── NO (need access to React context like auth provider)
└── Use wrapper component pattern (<RequireAuth> with <Outlet>)
```
### How to Load Data?
```
Data needed before route renders?
├── YES → Use route loader
│ ├── All data critical? → await everything in loader
│ └── Some data non-critical? → use defer() + <Await>
└── NO (data loaded after user interaction)
└── Use useEffect or event handler in component
```
---
## Reference Links
- [references/examples.md](references/examples.md) -- Complete routing patterns with TypeScript
- [references/api-table.md](references/api-table.md) -- React Router hooks and components reference
- [references/anti-patterns.md](references/anti-patterns.md) -- Common routing mistakes and fixes
- [references/api-table.md#official-sources](references/api-table.md#official-sources) -- Official React Router documentation links