With client-side routing the app intercepts link clicks, changes the URL through the History API (history.pushState) and renders the matching components without a full page reload. Server navigation requests a new HTML document for every URL.
Gains: no white flash, the layout and its state persist between pages, only the data and code that changed are fetched.
Back and forward: the router listens to popstate and re-renders for the new URL.
Costs: the first load ships the JS app; SEO and first paint need SSR or prerendering; the app must manage scroll position, focus and the document title itself.
Deep links: the server must answer every app URL with index.html, otherwise reloading /users/42 returns a 404.
ReactMiddle
Routing
React Router v7, nested routes and layouts, params and search params, loaders and actions, protected routes, code splitting by route
Questions closed
0 / 21
- 0Review
- 8Asked often
Questions
React Router v7 runs in declarative mode (<BrowserRouter> with <Routes>), data mode (createBrowserRouter plus RouterProvider, which adds loaders, actions and pending states) or framework mode (the former Remix: a Vite plugin, file-based routes, SSR and generated types).
Everything ships from one package, react-router; react-router-dom remains only as a re-export to ease migration.
Declarative: the v6 <Routes> style; it matches URLs, and data loading stays in components.
Data: routes are objects defined outside rendering, so the router can fetch data before rendering and handle forms. The usual choice for a new SPA.
Framework: a full-stack app with server rendering, code splitting per route and type-safe loaderData.
<Link> renders an <a> that navigates on the client, <NavLink> is a <Link> that knows whether it is active (for menus and tabs), and useNavigate navigates from code, for example after a submit. A plain <a href> reloads the whole page and drops the app's state.
<Link> keeps a real href, so Ctrl+click, "open in new tab" and crawlers still work; do not replace links with <button onClick={...}> that calls navigate.
<NavLink> passes isActive and isPending to className, style and children, and sets aria-current="page" on the active link.
navigate('/login', { replace: true }) replaces the current history entry; navigate(-1) goes back.
In data mode, prefer redirect('/done') from an action or loader over calling navigate in an effect.
Child routes are nested inside a parent route, and the parent renders <Outlet /> where the matched child should appear. The URL segments add up, the parent layout stays mounted while you move between children, and an index route fills the outlet at the parent's own path.
A route without path is a layout route: it adds UI (a shell, an auth check) without adding a URL segment.
Each matched level can have its own loader and error boundary, so a failing child shows its error inside the parent layout.
useOutletContext() reads a value the parent passes through <Outlet context={...} />.
Links are relative: inside /projects/:projectId, <Link to="settings"> goes to /projects/:projectId/settings.
Mark a segment with a colon in the path (users/:userId) and read it with useParams() in the component or from params in the loader. Params are always strings or undefined, so parse and validate them before use.
Optional segment: :lang?/about matches both /about and /en/about.
Splat: files/* matches the rest of the path, read as params['*'].
Changing only a param (from /users/1 to /users/2) keeps the same component mounted, so its local state survives; add key={userId} when it must reset.
In framework mode, generated types give params.userId the type string from the route path.
createBrowserRouter uses real paths through the History API, createHashRouter keeps the route after # so the server never sees it, and createMemoryRouter keeps history in memory for tests and embedded widgets. A refreshed deep link 404s because the server looks for a file at that path.
Fix for browser routing: a fallback that serves index.html for every unknown path: try_files $uri /index.html in nginx, try_files {path} /index.html in Caddy, a rewrite rule on Netlify or Vercel. GitHub Pages has no rewrites, so teams copy index.html to 404.html or prerender the routes.
Hash routing works on any static host with no configuration, but the URLs look worse and the hash never reaches the server, which rules out SSR.
Memory router does not touch the address bar; use it to render a route at a given URL in a unit test.
For an app deployed under a sub-path, pass basename so that links and matching include the prefix.
useSearchParams() returns a URLSearchParams for the current query string and a setter that navigates to a new query. Read values with get on every render and write through the setter, so the URL stays the single source of truth for filters, sorting and pagination.
Values are strings or null: parse numbers and fall back to defaults for missing or invalid input.
The setter replaces the whole query string; to change one key, use the functional form and keep the others.
Pass { replace: true } for frequent updates such as typing, so each keystroke does not add a history entry.
In data mode, loaders read the same values from new URL(request.url).searchParams and re-run when the query changes, so the list is fetched for the new filters automatically.
setParams({ sort }) replaces the entire query string with that object, so q and page are dropped. The setter works like navigating to a new ?..., not like merging into the current one.
The functional form receives the current URLSearchParams; changing one key and returning it keeps the rest.
Whether to keep page is a product decision: after a new sort, page 1 usually makes more sense, so delete it on purpose rather than by accident.
The same applies to navigate('?sort=price') and <Link to="?sort=price">: both replace the whole query string.
A loader is a function on a route that the router calls with params and the request before rendering that route; the component reads the result with useLoaderData(). Data for all matched routes starts loading in parallel at navigation time, instead of after each component mounts.
No waterfalls: with effects, a parent fetches, renders, and only then the child starts its request; the loaders of a nested URL all start at once.
No loading flags in every component: the router waits for the data (or streams it with <Await>), and useNavigation() gives one global pending state.
Race conditions handled: a new navigation cancels the previous one and aborts its request.signal.
Revalidation: after every action (a form submit), the loaders on the page run again, so the UI shows fresh data without manual refetching.
Loaders combine well with TanStack Query: the loader calls queryClient.ensureQueryData to start the fetch early, and the component reads from the cache.
An action is a route function that handles a non-GET submission: <Form method="post"> serializes the fields, the router calls the route's action with the request, and when it finishes, all loaders on the page revalidate, so the UI shows fresh data without manual refetching.
Read the input with await request.formData(); return validation errors (read them with useActionData()) or redirect('/projects/7').
useNavigation().state is 'submitting' while the action runs, which is enough to disable the button.
**useFetcher** submits or loads without navigating, for inline actions such as a like button or a todo checkbox, each with its own fetcher.state.
The form follows HTML semantics (name attributes, method), so it needs no useState per field and no onSubmit handler.
Both URLs match the same route, so React Router keeps EditUser mounted and only hands it new loader data. useState reads its argument on the first render only, so name and email keep user 1's values.
The loader did run: user is already user 2; only the state copied from it is stale. The same route renders the same component type at the same position, which React treats as an update, not a new mount.
The fix: render the form as a child with key={user.id}. A new key makes a new instance, so its state starts over from the new user, and an unsaved edit of user 1 cannot leak into user 2.
Uncontrolled fields have the same bug: defaultValue is also applied only on mount, so a <Form> with defaultValue inputs needs the same key.
Syncing in an effect is worse: useEffect(() => setName(user.name), [user]) paints one frame with the old values, and after any action on the page the revalidated loader returns a new user object, so the effect wipes what the user is typing.
Wrap private routes in a layout route that checks the session and either renders <Outlet /> or redirects to the login page, remembering where the user was going. In data mode, check in a loader or middleware and throw redirect('/login'), so the private page never renders.
Component guard: return <Navigate to="/login" replace state={{ from: location }} /> during render; never call navigate in an effect, which shows the private page first.
Loader guard: loaders of one navigation run in parallel, so a parent loader's redirect does not stop child loaders from starting. Middleware (React Router 7.9+) runs before every loader in the branch and is the one place for the check.
While the session is loading, render a spinner, not the redirect, or a signed-in user is bounced to login on every reload.
The real protection is on the server: client checks only hide UI; the API must reject requests without a valid session.
Role checks work the same way: a nested layout route for /admin that verifies the role.
The guard always renders <Outlet /> and redirects only in an effect, which runs after the private page has been committed and painted. The child also mounts and may start its own requests for private data.
Decide during render: return <Navigate replace /> instead of the outlet, so the private tree never mounts.
navigate('/login') without replace adds a history entry, so Back returns to the guarded URL and bounces again.
While the session is still loading, user is empty too; without a separate loading state a signed-in user is sent to login on every reload.
Use two mechanisms for two kinds of exit: useBlocker stops navigations inside the app (links, navigate, Back and Forward) and lets you show your own dialog; a beforeunload listener covers reloading, closing the tab and typing another address, where the browser shows its own prompt.
**useBlocker** needs data or framework mode. While its state is 'blocked', render a dialog that calls blocker.proceed() or blocker.reset().
Pass a function that compares currentLocation.pathname with nextLocation.pathname, so changing a search param or a tab inside the page does not ask.
A submit is a navigation too: a <Form> that posts to another URL is blocked while the form is dirty, while the redirect an action returns is not. Clear the dirty flag once the save succeeds.
**beforeunload**: event.preventDefault() triggers the prompt; its text is fixed by the browser, and it appears only after the user has interacted with the page. Listen only while there are unsaved changes.
**unstable_usePrompt** does the same with window.confirm; its unstable_ prefix is permanent, because the native dialog behaves differently across browsers.
Load each route's code only when it is visited: in data mode use the route's lazy property, which imports the component, loader and action on navigation; in declarative mode wrap React.lazy components in <Suspense>. Framework mode splits every route automatically.
**Why lazy beats React.lazy in data mode**: the router loads the route module and then runs its loader before rendering; with React.lazy the code arrives only when the component renders, and a data fetch inside it starts even later, a waterfall.
Since v7.5 lazy can also be an object of separate imports (loader, Component), so the loader does not wait for the component's chunk.
Keep small, always visited routes in the main bundle; split the heavy ones (dashboards, editors, admin screens).
Prefetch likely next routes: <Link prefetch="intent"> in framework mode, or a manual import() on hover.
Give routes an ErrorBoundary (or errorElement): anything thrown in that route's loader, action or render shows it in place of the route, inside the parent layout. Throw a Response or data() with a status for expected errors, and add a catch-all * route for unknown URLs.
useRouteError() returns what was thrown; isRouteErrorResponse(error) tells a thrown response (with status, statusText, data) from an unexpected exception.
Errors bubble to the nearest boundary up the tree, so a root boundary is the safety net and nested ones keep the rest of the layout usable.
A loader that finds nothing should throw data('Not found', { status: 404 }) rather than return null and leave the component to handle it.
Without any boundary, React Router shows its default developer error screen.
useNavigation() exposes the global state (idle, loading, submitting) for a progress bar. For slow, non-critical data, return the promise from the loader without awaiting it and render it with <Suspense> and <Await>, so the page appears as soon as the critical data arrives.
By default the router keeps the old page on screen until all loaders resolve, which feels right for fast loads and frozen for slow ones.
<NavLink> gets isPending for the link being navigated to.
Await the critical data (the main entity, the title) and stream the secondary data (comments, recommendations); the promise must be a field of the returned object, not the return value itself.
useNavigation().location tells which page is loading, for a skeleton of the destination.
Avoid spinners that flash for 100 ms: delay the indicator so fast navigations show none.
With client-side routing the document never changes, so the window keeps the previous page's scroll position, and on Back the browser restores the old offset before the data has rendered. <ScrollRestoration /> takes over: new pages start at the top, Back and Forward return to the saved position.
How it works: it switches history.scrollRestoration to 'manual', saves window.scrollY per history entry in sessionStorage, and after each navigation, once the loaders have finished, restores the saved offset or scrolls to the #hash target or to the top.
Render it once in the root route. It needs data or framework mode; with <BrowserRouter> scroll to the top yourself: window.scrollTo(0, 0) in an effect on pathname.
**getKey** defaults to location.key, one position per history entry; return location.pathname to restore a list's position even when the user comes back through a link.
**preventScrollReset** on a <Link>, <Form> or navigate() keeps the position for changes inside the page, such as tabs or filters.
Only the window is handled: a layout that scrolls inside <main> with overflow: auto must save and restore that element's scrollTop itself.
Data loaded in an effect arrives after the restore, while the page is still short; load it in a loader or serve it from a cache.
v7 is a non-breaking upgrade for apps that enabled all v6 future flags: the packages merge into react-router, Remix becomes its framework mode, and loaders return plain objects instead of json(). Migrate by turning on every v7_ flag in v6, fixing the warnings, then switching the imports.
One package: import everything from react-router; react-router-dom is kept only as a re-export.
Future flags become defaults: v7_startTransition (router state updates are wrapped in startTransition), v7_relativeSplatPath, v7_fetcherPersist, v7_normalizeFormMethod, v7_partialHydration, v7_skipActionErrorRevalidation.
Data utilities: json() and defer() are deprecated: return objects, return promises inside them for streaming, and use data() when you need a status or headers.
Framework mode: Remix v2 apps move to React Router v7 with a codemod, and get the Vite plugin, routes.ts and generated types.
v7 requires React 18 or newer and Node 20 or newer.
React Router keeps only the latest navigation: a new click aborts the pending one's loaders through request.signal, so stale responses never reach the UI. After every action all active loaders revalidate by default, and a route's shouldRevalidate can opt out when its data cannot have changed.
Cancellation
Passing request.signal to fetch turns the cancellation into a real aborted HTTP request; without it the request completes and its result is discarded.
Two quick submissions of the same form: the first navigation is interrupted, but the server may already have processed it, so mutations must be idempotent or guarded on the server.
Fetchers run independently, several can be in flight, and the router makes sure an older response does not overwrite data from a newer one.
Revalidation
Triggers: a completed action, a change of search params on the same route, or useRevalidator().revalidate().
shouldRevalidate({ currentUrl, nextUrl, formMethod, defaultShouldRevalidate }) returns false to skip, for example for a sidebar loader that ignores the query string. Skipping too eagerly gives stale UI, a harder bug than an extra request.
Since v7, an action that returns or throws a 4xx or 5xx response skips revalidation by default.
With a query cache
When loaders read through TanStack Query (ensureQueryData), revalidation hits the cache, and freshness is governed by staleTime and invalidateQueries.
Framework mode is React Router plus the former Remix: a Vite plugin with routes.ts, server loader and action, SSR, prerendering or SPA output, automatic code splitting and generated route types. Choose it for a full-stack or SEO-sensitive app built on web-standard Request and Response.
What it adds over data mode
Server code: loader and action run on the server, so secrets and database access stay there, and the HTML arrives with the data; clientLoader covers browser-only data.
Route modules: one file exports loader, action, meta, links, ErrorBoundary and the default component.
Generated types: Route.LoaderArgs and Route.ComponentProps type params and loaderData from the route config.
Rendering choice: ssr: true, prerender for static pages, or ssr: false for a plain SPA.
Versus Next.js App Router
Next.js is built around React Server Components, streaming and its own caching layers; it deploys anywhere, but some features work best on Vercel.
React Router framework mode keeps the simpler loader and client component model and deploys to Node, Cloudflare or other runtimes through adapters; its Server Components support is newer and less mature.
When data mode is enough
An internal tool or dashboard behind a login with a separate API: no SEO, no need for SSR, and a static SPA is cheaper to host.