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.