React Router Cheatsheet
TypeScript and Testing
Use this React Router reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Generated Route Types
Framework mode generates a types module per route into
.react-router/types/, imported as ./+types/<route>. Params come from
the path in routes.ts and loader data from the loader's return type, so
nothing is asserted by hand.
// app/routes/product.tsx import type { Route } from "./+types/product"; export async function loader({ params }: Route.LoaderArgs) { return { product: await getProduct(params.id) }; } export async function action({ request }: Route.ActionArgs) { return { ok: true }; } export function meta({ loaderData }: Route.MetaArgs) { return [{ title: loaderData.product.name }]; } export default function Product({ loaderData, actionData, params, matches, }: Route.ComponentProps) { loaderData.product; // inferred from loader actionData?.ok; // inferred from action params.id; // inferred from the route path }
| Type | For |
|---|---|
Route.ComponentProps | The default export's props |
Route.LoaderArgs / Route.ClientLoaderArgs | Loader args |
Route.ActionArgs / Route.ClientActionArgs | Action args |
Route.MetaArgs | The meta function |
Route.LinksFunction | The links function |
Route.HeadersArgs | The headers function |
Route.ErrorBoundaryProps | The ErrorBoundary export |
Route.MiddlewareFunction | An entry in the middleware array |
Wiring tsconfig
{
"include": [".react-router/types/**/*", "app/**/*"],
"compilerOptions": {
"rootDirs": [".", "./.react-router/types"],
"verbatimModuleSyntax": true
}
}rootDirs is what lets ./+types/product resolve as a sibling of
app/routes/product.tsx. If your project has several tsconfig files, make
the change in whichever one includes the app directory.
{
"scripts": { "typecheck": "react-router typegen && tsc" }
}Add .react-router/ to .gitignore. The Vite plugin regenerates on every
routes.ts edit during react-router dev, but CI needs the explicit
typegen step before tsc.
Type Safe Links and Fetchers
import { href } from "react-router"; <Link to={href("/products/:id", { id: product.id })}>View</Link>
href is checked against your route config, so a renamed route turns
stale links into type errors instead of runtime 404s.
// a fetcher typed from the route it talks to import type { action } from "../routes/subscribe"; const fetcher = useFetcher<typeof action>(); fetcher.data?.errors; // typed
// server context is typed contexts now, not a module augmentation import { createContext, RouterContextProvider } from "react-router"; export const dbContext = createContext<Database>(); // in a custom server createRequestHandler({ build, getLoadContext() { const context = new RouterContextProvider(); context.set(dbContext, db); return context; }, });
v8 removed the AppLoadContext type. getLoadContext must return a
RouterContextProvider, and a plain object is no longer accepted.
Unit Testing Components
createRoutesStub builds a router context so a component that calls
useActionData, useLoaderData, or renders a <Link> can be tested in
isolation.
import { createRoutesStub } from "react-router"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { LoginForm } from "./LoginForm"; test("shows validation errors", async () => { const Stub = createRoutesStub([ { path: "/login", Component: LoginForm, action() { return { errors: { username: "Username is required" } }; }, }, ]); render(<Stub initialEntries={["/login"]} />); userEvent.click(screen.getByText("Login")); await waitFor(() => screen.findByText("Username is required")); });
Stub objects take path, Component, loader, action, children, and
ErrorBoundary. A second argument accepts a RouterContextProvider when
the code under test reads middleware context.
createRoutesStubis for reusable components, not whole route modules. TheRoute.*types come from your real route tree, so a stub'smatcheswill never line up and you get a type error. Test route level behavior with Playwright or Cypress instead, or silence it with@ts-expect-errorif you must.
Testing Loaders and Actions
A loader is a plain async function over a Request, so the cheapest test
skips React completely.
import { loader, action } from "./routes/users"; test("filters by query", async () => { const request = new Request("http://test/users?q=ada"); const data = await loader({ request, params: {}, context } as any); expect(data.users).toHaveLength(1); }); test("redirects when signed out", async () => { const request = new Request("http://test/dashboard"); await expect(loader({ request, params: {}, context } as any)) .rejects.toMatchObject({ status: 302 }); }); test("rejects an empty name", async () => { const body = new FormData(); body.set("name", ""); const request = new Request("http://test/users/1", { method: "POST", body, }); const res = await action({ request, params: { id: "1" }, context } as any); expect(res.init.status).toBe(400); });
Because middleware is always on, context is a RouterContextProvider,
so build one and set() whatever the handler reads.
Test Runner Setup
// vitest.config.ts, no React Router plugin for unit tests import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], test: { environment: "jsdom" }, });
| Layer | Tool |
|---|---|
| Reusable components | createRoutesStub plus Testing Library |
| Loaders and actions | Call them directly with a Request |
| Whole routes, SSR, hydration | Playwright or Cypress against the dev build |
| Types | react-router typegen && tsc in CI |
Do not put the React Router Vite plugin in the Vitest config. It builds a
server bundle and expects routes.ts, which unit tests do not need.