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.

TypeScript

// Framework mode: types are generated per route
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 default function Product({ loaderData, actionData, params }: Route.ComponentProps) {
  loaderData.product;   // typed from the loader's return
  params.id;            // typed from the route path
}
// tsconfig.json
{
  "include": [".react-router/types/**/*", "app/**/*"],
  "compilerOptions": {
    "rootDirs": [".", "./.react-router/types"]
  }
}
npx react-router typegen        # regenerate route types
npx react-router typegen --watch
// Data mode: annotate manually
import { useLoaderData } from "react-router";

async function loader() {
  return { user: await getUser() };
}
type LoaderData = Awaited<ReturnType<typeof loader>>;

function User() {
  const { user } = useLoaderData() as LoaderData;
}

The generated ./+types/* modules are the reason framework mode is worth it on a TypeScript project: params, loader data, and action data are all inferred from the route config rather than asserted by hand.

Testing

import { render, screen } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router";

test("renders the user page", () => {
  render(
    <MemoryRouter initialEntries={["/users/42"]}>
      <Routes>
        <Route path="users/:id" element={<User />} />
      </Routes>
    </MemoryRouter>
  );
  expect(screen.getByText("42")).toBeInTheDocument();
});
// Data routes with loaders
import { createMemoryRouter, RouterProvider } from "react-router";

const router = createMemoryRouter(
  [{ path: "/users/:id", element: <User />, loader: () => ({ name: "Ada" }) }],
  { initialEntries: ["/users/42"] }
);

render(<RouterProvider router={router} />);
expect(await screen.findByText("Ada")).toBeInTheDocument();
// Assert on navigation
const router = createMemoryRouter(routes, { initialEntries: ["/"] });
render(<RouterProvider router={router} />);
await userEvent.click(screen.getByRole("link", { name: "About" }));
expect(router.state.location.pathname).toBe("/about");

Use MemoryRouter for declarative components and createMemoryRouter whenever a loader or action is involved. Stubbing the loader inline, as above, keeps the test focused on the component.

Typing a Data Router

import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";

export async function loader({ params, request }: LoaderFunctionArgs) {
  const id = params.id;                 // string | undefined
  if (!id) throw new Response("Bad request", { status: 400 });
  return { user: await getUser(id) };
}

export async function action({ request }: ActionFunctionArgs) {
  const form = await request.formData();
  return { ok: true };
}
// A typed useLoaderData helper
function useTypedLoaderData<T extends (...a: never[]) => unknown>() {
  return useLoaderData() as Awaited<ReturnType<T>>;
}

const { user } = useTypedLoaderData<typeof loader>();

Framework mode's generated ./+types/* modules make all of this unnecessary, which is the main reason to prefer it on a TypeScript codebase.

Testing Loaders and Actions Directly

A loader is a plain function that takes a Request, so the cheapest test skips React entirely.

import { loader } from "./routes/users";

test("filters by query", async () => {
  const request = new Request("http://test/users?q=ada");
  const data = await loader({ request, params: {}, context: {} });
  expect(data.users).toHaveLength(1);
});

test("redirects when unauthenticated", async () => {
  const request = new Request("http://test/dashboard");
  await expect(loader({ request, params: {}, context: {} }))
    .rejects.toMatchObject({ status: 302 });
});
// An action, with a real FormData body
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: {} });
  expect(res.init.status).toBe(400);
});

Testing Forms End to End

test("saves and revalidates", async () => {
  const router = createMemoryRouter(
    [{
      path: "/users/:id",
      element: <EditUser />,
      loader: () => ({ user: { id: "1", name: "Ada" } }),
      action: async ({ request }) => {
        const form = await request.formData();
        return { saved: form.get("name") };
      },
    }],
    { initialEntries: ["/users/1"] }
  );

  render(<RouterProvider router={router} />);
  await userEvent.clear(await screen.findByLabelText("Name"));
  await userEvent.type(screen.getByLabelText("Name"), "Grace");
  await userEvent.click(screen.getByRole("button", { name: "Save" }));
  expect(await screen.findByText(/Grace/)).toBeInTheDocument();
});

Stubbing loader and action inline keeps the test about the component's behavior instead of your data layer.