Routing with React Router
Route configuration and nested layouts, params and search params, loaders and actions, lazy routes, and showing pending navigation state.
Routes and nested layouts
import { createBrowserRouter, RouterProvider, Outlet, Link } from 'react-router-dom'
function RootLayout() {
return (
<div className="shell">
<header>
<Link to="/">Home</Link>
<Link to="/guides">Guides</Link>
</header>
<main>
<Outlet /> {/* the matched child route renders here */}
</main>
</div>
)
}
const router = createBrowserRouter([
{
path: '/',
element: <RootLayout />,
errorElement: <NotFound />,
children: [
{ index: true, element: <Home /> },
{ path: 'guides', element: <Guides /> },
{ path: 'guides/:slug', element: <Guide /> }
]
}
])
export default function App() {
return <RouterProvider router={router} />
}- A parent route renders the layout and the
Outlet; the child renders into that position, so shared chrome is written once. index: truemarks the child that matches the parent path exactly, which is the usual home route inside a layout.errorElementcatches render errors and thrown responses for that branch, so one broken route does not blank the app.- Use
Linkrather than an anchor for in-app navigation: it prevents the full page load and keeps client state alive. - Route configuration is plain data, so a route can be generated, filtered or tested without rendering anything.
Params, loaders and actions
import { useParams, useSearchParams, useLoaderData, useNavigation } from 'react-router-dom'
function Guide() {
const guide = useLoaderData() // typed data, already fetched
const { slug } = useParams() // a dynamic segment, always a string
const [params, setParams] = useSearchParams()
const tab = params.get('tab') ?? 'overview'
return (
<>
<h1>{guide.title}</h1>
<nav>
<button type="button" onClick={() => setParams({ tab: 'examples' })}>Examples</button>
</nav>
<p>tab: {tab}</p>
</>
)
}
// a loader runs before the route renders, so the component never holds a loading flag
async function guideLoader({ params }) {
const response = await fetch('/api/guides/' + params.slug)
if (response.status === 404) throw new Response('Not found', { status: 404 })
if (!response.ok) throw new Response('Failed', { status: response.status })
return response.json()
}
// an action handles a form submission and can redirect
async function deleteAction({ params }) {
await fetch('/api/guides/' + params.slug, { method: 'DELETE' })
return redirect('/guides')
}
function PendingBar() {
const navigation = useNavigation()
return navigation.state === 'loading' ? <div className="bar" role="status" /> : null
}| State | Where it belongs | Why |
|---|---|---|
| The current page or record id | The URL | It is shareable, bookmarkable and survives a refresh |
| A filter the user might send to someone | A search param | It can be copied out of the address bar |
| The open tab, when it is linkable | A search param | Back and forward then behave as expected |
| A modal that should survive a refresh | A search param or a nested route | It becomes addressable |
| Draft text in an input | Component state | It is not something anyone would link to |
| Hover and focus | Component state or CSS | It is transient by design |
Lazy routes and shared layout cache
import { lazy, Suspense } from 'react'
import { useRouteLoaderData } from 'react-router-dom'
// route-level splitting: the chunk is fetched the first time the route matches
const Guide = lazy(() => import('./routes/Guide'))
const router = createBrowserRouter([
{
path: 'guides/:slug',
element: (
<Suspense fallback={<RouteSkeleton />}>
<Guide />
</Suspense>
),
loader: guideLoader
}
])
// a parent loader can be read from a child without fetching again
function Sidebar() {
const data = useRouteLoaderData('root') // the loader result of that route id
return <p>{data.count} guides</p>
}- React Router deduplicates loader calls that share a route, so a parent loader is not re-run because a child also needs it.
- A thrown
Responsefrom a loader reaches the nearesterrorElement, which is how a 404 renders a real not-found page instead of an exception. - Keep the loader free of UI concerns: it returns data or throws, and the component decides how the data looks.
- Invalidate by calling
revalidate()after a mutation that a loader depends on, rather than duplicating fetch logic in the component.
💡
A route loader is not a cache. It runs on every navigation to that route, so pair it with a query library when you need caching, retries and background refetching, or accept that each visit refetches.
FAQ
Which router should a new app use?
React Router for a client-rendered single-page app; a framework router such as Next.js or Remix when you also want server rendering, because there the router and the data layer are designed together.
Why is my route param a string when the id is a number?
URL segments are text. Parse explicitly, then validate:
const id = Number(params.id) followed by a check, and throw a Response when it is not a valid id.Related
Custom hooks and sharing logic Server components, actions and the use API
Last refreshed 2026-09-18.