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>; }
| Arg | What it is |
|---|---|
params | Route params, typed from the path in routes.ts |
request | The raw incoming Request, passed through untouched in v8 |
url | The normalized URL, without .data suffixes or internal params |
context | A 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
| API | Gives you |
|---|---|
The loaderData prop | This 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.