React Router Cheatsheet

Loaders

Use this React Router reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

Server Loaders

A route module's loader runs on the server before the route renders. Return a plain object. json() has not been needed since v7, and the loader is stripped from the client bundle entirely.

// app/routes/product.tsx
import { data } from "react-router";
import type { Route } from "./+types/product";

export async function loader({ params, request }: Route.LoaderArgs) {
  const product = await db.product.find(params.id);
  if (!product) throw data("Not Found", { status: 404 });
  return { product, now: Date.now() };
}

export default function Product({ loaderData }: Route.ComponentProps) {
  return <h1>{loaderData.product.name}</h1>;
}
ArgWhat it is
paramsRoute params, typed from the path in routes.ts
requestThe raw incoming Request, passed through untouched in v8
urlThe normalized URL, without .data suffixes or internal params
contextA RouterContextProvider, read with context.get(myContext)

Loaders for every matched route run in parallel, so a three level nested route is one round trip and not a waterfall.

Client Loaders

clientLoader runs in the browser. Use it for data that only exists client side, or to call an API directly and skip your own server.

export async function clientLoader({
  params,
  serverLoader,
}: Route.ClientLoaderArgs) {
  const cached = cache.get(params.id);
  if (cached) return cached;
  const data = await serverLoader();   // call the server loader too
  cache.set(params.id, data);
  return data;
}

// run it on the very first load as well, instead of using SSR data
clientLoader.hydrate = true as const;

export function HydrateFallback() {
  return <p>Loading...</p>;
}

- With only loader, data is server rendered. - With only clientLoader, the route has no server data at all, which is what SPA mode (ssr: false) uses. - With both, clientLoader wraps the server loader and can cache it. - clientLoader.hydrate = true requires a HydrateFallback unless the route also has a server loader.

Reading Data Elsewhere

APIGives you
The loaderData propThis route's data, fully typed, inside a route module
useLoaderData()This route's data from a nested component
useRouteLoaderData(id)Another matched route's data, by route id
useMatches()Every match, with id, params, loaderData, handle
useRevalidator()Force every loader on the page to run again
import { useRouteLoaderData } from "react-router";
import type { loader as rootLoader } from "../root";

const rootData = useRouteLoaderData<typeof rootLoader>("root");

Pull shared data such as the signed in user from a root loader and read it with useRouteLoaderData("root") instead of refetching per child.

Revalidation

After any action, every loader on the page runs again. shouldRevalidate opts a route out of that.

import type { ShouldRevalidateFunctionArgs } from "react-router";

export function shouldRevalidate({
  currentParams,
  nextParams,
  formMethod,
  defaultShouldRevalidate,
}: ShouldRevalidateFunctionArgs) {
  if (currentParams.id !== nextParams.id) return true;
  if (formMethod === "POST") return true;
  return false;
}

Loaders also revalidate on useRevalidator(), on a <Form method="get"> navigation, and when search params change. Getting this wrong shows up as stale UI after a save.

Errors and Status

import { data, redirect } from "react-router";

export async function loader({ request }: Route.LoaderArgs) {
  const user = await getUser(request);
  if (!user) throw redirect("/login");

  const record = await find(user.id);
  if (!record) throw data("Not Found", { status: 404 });

  return data(
    { record },
    { headers: { "Cache-Control": "max-age=300, s-maxage=3600" } }
  );
}

Throwing sends the nearest ErrorBoundary a route error response. Returning data(value, init) keeps the typed value and attaches a status or headers. A thrown Response still works, data() is just the documented framework idiom now.

Streaming with Await

Return a promise instead of awaiting it, and the document streams the fast part immediately.

import { Suspense } from "react";
import { Await } from "react-router";

export async function loader({ params }: Route.LoaderArgs) {
  return {
    product: await getProduct(params.id),   // awaited, blocks the render
    reviews: getReviews(params.id),         // promise, streams in later
  };
}

export default function Product({ loaderData }: Route.ComponentProps) {
  return (
    <>
      <h1>{loaderData.product.name}</h1>
      <Suspense fallback={<p>Loading reviews...</p>}>
        <Await resolve={loaderData.reviews} errorElement={<p>Failed</p>}>
          {(reviews) => <ReviewList reviews={reviews} />}
        </Await>
      </Suspense>
    </>
  );
}

Every streamed promise needs a Suspense boundary and a rejection handler, either errorElement on Await or a .catch() before you return it. An unhandled rejection kills the whole response.

Middleware

Middleware is always enabled in v8, with no future flag. It runs around the loaders of the routes it is declared on, which makes it the right place for a shared auth check.

import { createContext, redirect } from "react-router";
import type { Route } from "./+types/layout";

export const userContext = createContext<User | null>(null);

const authMiddleware: Route.MiddlewareFunction = async (
  { request, context },
  next
) => {
  const user = await getUserFromSession(request);
  if (!user) throw redirect("/login");
  context.set(userContext, user);
  return next();
};

export const middleware: Route.MiddlewareFunction[] = [authMiddleware];

export async function loader({ context }: Route.LoaderArgs) {
  const user = context.get(userContext);
  return { user };
}

clientMiddleware is the browser side equivalent. The context handed to middleware, loaders, and actions is always a RouterContextProvider in v8, so a custom server's getLoadContext must return one.