src/skills/web-routing-react-router/SKILL.md
Client-side routing with data APIs — loaders, actions, error boundaries, search params, nested layouts, and code splitting
npx skillsauth add agents-inc/skills web-routing-react-routerInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Quick Guide: React Router v7 has three modes: Declarative (
<BrowserRouter>), Data (createBrowserRouter), and Framework (Vite plugin). This skill covers Data Mode — the sweet spot for SPAs needing loaders, actions, and pending states without a full framework. All imports come from"react-router"(thereact-router-dompackage is removed).defer()andjson()are removed in v7 — return plain objects from loaders. Form method values are now uppercase ("POST", not"post").
<critical_requirements>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST use createBrowserRouter + <RouterProvider> for Data Mode — NEVER use <BrowserRouter> with <Routes> if you need loaders, actions, or fetchers)
(You MUST import from "react-router" — the react-router-dom package is removed in v7. All exports, including RouterProvider and createBrowserRouter, come from "react-router")
(You MUST return plain objects from loaders — json() and defer() are removed in v7. Return { data } directly or use Response.json())
(You MUST use throw redirect() in loaders and shared helpers to short-circuit execution — return redirect() also works but does not stop execution in helper function call stacks)
(You MUST use errorElement or ErrorBoundary on routes — unhandled loader/action errors crash the entire router)
</critical_requirements>
Auto-detection: React Router, createBrowserRouter, RouterProvider, useLoaderData, useActionData, useNavigation, useSearchParams, useFetcher, useRouteError, useParams, useNavigate, Outlet, NavLink, Form, loader, action, errorElement, ErrorBoundary, redirect, isRouteErrorResponse, route.lazy, useOutletContext, shouldRevalidate, useRevalidator
When to use:
Key patterns covered:
createBrowserRouter and RouterProvider<Outlet /> and useOutletContextLink, NavLink, useNavigate, redirecterrorElement, useRouteError, isRouteErrorResponseuseSearchParamsuseFetcherroute.lazyuseNavigationWhen NOT to use:
<BrowserRouter> is sufficient)For quick API reference (hooks, components, route options), see reference.md.
React Router v7 treats the router as a data layer, not just a URL matcher. Routes define what data to load (loader), what mutations to handle (action), and what errors to catch (errorElement) — all before the component renders. This moves data orchestration out of components and into the route tree, eliminating loading waterfalls and duplicated error handling.
Core principles:
errorElement catches errors at the nearest route. Unhandled errors bubble to the parent.shouldRevalidate opts in.)useFetcher handles inline forms, buttons, and background saves without changing the URL.When to use Data Mode:
When NOT to use:
<Link> — Declarative Mode is simplerDefine routes as objects with createBrowserRouter. Pass the router to <RouterProvider>. This is the entry point for all Data Mode features.
import { createBrowserRouter, RouterProvider } from "react-router";
import { RootLayout } from "./layouts/root-layout";
import { HomePage } from "./pages/home";
import { PostsPage, postsLoader } from "./pages/posts";
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
errorElement: <RootError />,
children: [
{ index: true, element: <HomePage /> },
{
path: "posts",
element: <PostsPage />,
loader: postsLoader,
},
],
},
]);
function App() {
return <RouterProvider router={router} />;
}
Why: Route config lives outside React rendering, enabling the router to call loaders before components mount. errorElement at the root catches any unhandled error in the tree.
See examples/core.md for complete setup with nested routes and error handling.
Loaders fetch data before the route renders. Actions handle form mutations. Both receive { request, params }. After an action completes, all active loaders automatically revalidate.
// Loader: runs before render
export async function postsLoader() {
const response = await fetch("/api/posts");
if (!response.ok) throw new Response("Failed to load", { status: 500 });
return response.json(); // or return { posts: await response.json() }
}
// Action: handles Form submissions
export async function createPostAction({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get("title") as string;
const post = await createPost({ title });
return redirect(`/posts/${post.id}`);
}
// Route config
{
path: "posts",
element: <PostsPage />,
loader: postsLoader,
action: createPostAction,
}
Why: Loaders run in parallel for sibling routes — no waterfall. Actions revalidate all active loaders automatically, keeping the UI in sync. Throwing a Response from a loader triggers errorElement.
See examples/data-loading.md for actions, useFetcher, and revalidation patterns.
Every route can define errorElement to catch errors from its loader, action, or component. Use useRouteError() to access the error and isRouteErrorResponse() to distinguish HTTP errors from unexpected exceptions.
import { useRouteError, isRouteErrorResponse } from "react-router";
function RouteError() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div role="alert">
<h2>{error.status}: {error.statusText}</h2>
<p>{error.data}</p>
</div>
);
}
return (
<div role="alert">
<h2>Unexpected Error</h2>
<pre>{error instanceof Error ? error.message : "Unknown error"}</pre>
</div>
);
}
Why: isRouteErrorResponse distinguishes thrown Response objects (4xx/5xx from loaders) from unexpected exceptions. Without errorElement, a single loader failure crashes the entire router.
See examples/error-handling.md for nested error boundaries and retry patterns.
Parent routes render <Outlet /> where child routes appear. This creates persistent layouts (sidebars, headers) that do not unmount on child navigation.
import { Outlet, NavLink } from "react-router";
function DashboardLayout() {
return (
<div className="dashboard">
<nav>
<NavLink to="/dashboard" end>Overview</NavLink>
<NavLink to="/dashboard/analytics">Analytics</NavLink>
</nav>
<main>
<Outlet />
</main>
</div>
);
}
// Route config
{
path: "dashboard",
element: <DashboardLayout />,
children: [
{ index: true, element: <DashboardOverview /> },
{ path: "analytics", element: <AnalyticsPage />, loader: analyticsLoader },
],
}
Why: Layout routes share UI across child routes without re-rendering. <Outlet /> is the slot where the matched child renders. Forgetting <Outlet /> means child routes render nothing.
See examples/layouts.md for useOutletContext, pathless layouts, and auth guards.
useNavigation() exposes the current navigation state. Use it for global loading indicators and optimistic UI during form submissions.
import { useNavigation } from "react-router";
function GlobalSpinner() {
const navigation = useNavigation();
const isNavigating = navigation.state !== "idle";
if (!isNavigating) return null;
return <div className="global-spinner" aria-busy="true">Loading...</div>;
}
Why: navigation.state is "idle", "loading" (loader running), or "submitting" (action running). Place the spinner in your root layout for app-wide pending indication.
useFetcher handles mutations that should not trigger a full page navigation — inline forms, toggle buttons, auto-save, "add to cart" actions.
import { useFetcher } from "react-router";
function DeleteButton({ postId }: { postId: string }) {
const fetcher = useFetcher();
const isDeleting = fetcher.state !== "idle";
return (
<fetcher.Form method="POST" action={`/posts/${postId}/delete`}>
<button type="submit" disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete"}
</button>
</fetcher.Form>
);
}
Why: Unlike <Form>, fetcher.Form does not change the URL. Multiple fetchers can be active simultaneously. Each fetcher has independent state and data. After the action completes, active loaders revalidate.
See examples/data-loading.md for useFetcher patterns including optimistic UI.
Use route.lazy to defer loading route modules until navigation. In v7.5+, the object-based API allows granular splitting of individual properties.
const router = createBrowserRouter([
{
path: "/",
element: <RootLayout />,
children: [
{ index: true, element: <HomePage /> },
{
path: "admin",
lazy: () => import("./pages/admin"),
// The module must export: Component, loader, action, errorElement, etc.
},
],
},
]);
// v7.5+ granular lazy loading
{
path: "admin",
lazy: {
loader: async () => (await import("./pages/admin.loader")).loader,
Component: async () => (await import("./pages/admin.component")).AdminPage,
},
}
Why: route.lazy keeps the initial bundle small. The function-based API loads everything at once. The v7.5+ object-based API lets you split loader, component, and action into separate chunks for parallel downloads.
See examples/error-handling.md for lazy loading patterns and preloading.
</patterns><decision_framework>
What do you need from routing?
+-- Just URL matching and Links?
| -> Declarative Mode (<BrowserRouter>)
+-- Data loading, actions, pending states?
| -> Data Mode (createBrowserRouter) - this skill
+-- SSR, file-based routing, full framework?
| -> Framework Mode (Vite plugin)
What does the logic do?
+-- Fetch data for the component?
| -> loader (runs before render, parallel with siblings)
+-- Handle a form submission or mutation?
| -> action (processes FormData, returns result or redirects)
+-- Mutation without navigation (inline form, button)?
| -> useFetcher (independent state, no URL change)
+-- Check auth and redirect?
| -> loader (throw redirect() if unauthorized)
Need to navigate?
+-- Clickable element in JSX?
| +-- YES -> <Link> or <NavLink>
| +-- NO -> After side effect (form submit, mutation)?
| +-- YES -> useNavigate() or redirect() in action
| +-- NO -> In a loader?
| +-- YES -> throw redirect()
| +-- NO -> router.navigate()
Where to put error handling?
+-- Root-level catch-all?
| -> errorElement on the root route (catches everything)
+-- Per-page error UI?
| -> errorElement on each route (fine-grained)
+-- Data-level not-found?
| -> throw new Response("Not Found", { status: 404 }) in loader
+-- Distinguish HTTP errors from exceptions?
| -> isRouteErrorResponse(error) in errorElement component
Need shared UI across routes?
+-- Persistent layout (sidebar, header)?
| -> Parent route with <Outlet />
+-- Pass data from layout to children?
| -> <Outlet context={...} /> + useOutletContext()
+-- Auth guard wrapping multiple routes?
| -> Pathless layout route with loader that redirects
+-- Route should escape parent layout?
| -> Move route outside the parent in the config
</decision_framework>
<red_flags>
High Priority Issues:
<BrowserRouter> with <Routes> and expecting loaders/actions to work (they are Data Mode features requiring createBrowserRouter)"react-router-dom" in v7 (package is removed, use "react-router" for all imports)json() or defer() in v7 (removed, return plain objects from loaders)return redirect() inside shared helper functions (execution continues after the return — use throw redirect() to stop the entire call stack)errorElement on the root route (unhandled loader errors crash the entire router with a white screen)Medium Priority Issues:
<Outlet /> in layout routes (child routes render nothing, no error shown)useLoaderData() in a component that does not belong to a route with a loader (returns undefined, hard to debug)useEffect when a loader would eliminate the waterfallnavigation.formMethod === "post" (v7 uppercases form methods: use "POST")<Form> components when only one should navigate (use useFetcher for the others)Common Mistakes:
navigate(-1) for "back" without considering what "back" means when the user deep-linked (there is no history entry)params as numbers without parsing (params.id is always a string)Gotchas and Edge Cases:
shouldRevalidate opts back inuseFetcher does not trigger useNavigation state changes (fetcher state is independent)shouldRevalidate can suppress revalidation per-route, but defaults to revalidating everything after actionsuseSearchParams setter does NOT support React's setState queueing — multiple calls in the same tick do not build on previous valuesroute.lazy cannot define path, index, children, or id — those must be statically defined in the route configrequest in loaders and actions is a standard Web Request — use request.formData(), request.json(), etc.Response object in a loader makes it available as an ErrorResponse via useRouteError — use isRouteErrorResponse() to type-narrow</red_flags>
<critical_reminders>
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST use createBrowserRouter + <RouterProvider> for Data Mode — NEVER use <BrowserRouter> with <Routes> if you need loaders, actions, or fetchers)
(You MUST import from "react-router" — the react-router-dom package is removed in v7. All exports, including RouterProvider and createBrowserRouter, come from "react-router")
(You MUST return plain objects from loaders — json() and defer() are removed in v7. Return { data } directly or use Response.json())
(You MUST use throw redirect() in loaders and shared helpers to short-circuit execution — return redirect() also works but does not stop execution in helper function call stacks)
(You MUST use errorElement or ErrorBoundary on routes — unhandled loader/action errors crash the entire router)
Failure to follow these rules will cause white screens from unhandled errors, broken data loading from wrong imports, and missed redirects in helper functions.
</critical_reminders>
development
Material Design component library for Vue 3
development
VitePress 1.x — Vue-powered static site generator for documentation sites, built on Vite
tools
Docusaurus 3.x documentation framework — site configuration, docs/blog plugins, sidebars, versioning, MDX, swizzling, and deployment
development
TanStack Form patterns - useForm, form.Field, validators, arrays, linked fields, createFormHook, type safety