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
}
TypeFor
Route.ComponentPropsThe default export's props
Route.LoaderArgs / Route.ClientLoaderArgsLoader args
Route.ActionArgs / Route.ClientActionArgsAction args
Route.MetaArgsThe meta function
Route.LinksFunctionThe links function
Route.HeadersArgsThe headers function
Route.ErrorBoundaryPropsThe ErrorBoundary export
Route.MiddlewareFunctionAn 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.

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.

createRoutesStub is for reusable components, not whole route modules. The Route.* types come from your real route tree, so a stub's matches will never line up and you get a type error. Test route level behavior with Playwright or Cypress instead, or silence it with @ts-expect-error if 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" },
});
LayerTool
Reusable componentscreateRoutesStub plus Testing Library
Loaders and actionsCall them directly with a Request
Whole routes, SSR, hydrationPlaywright or Cypress against the dev build
Typesreact-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.