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: true marks the child that matches the parent path exactly, which is the usual home route inside a layout.
  • errorElement catches render errors and thrown responses for that branch, so one broken route does not blank the app.
  • Use Link rather 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
}
StateWhere it belongsWhy
The current page or record idThe URLIt is shareable, bookmarkable and survives a refresh
A filter the user might send to someoneA search paramIt can be copied out of the address bar
The open tab, when it is linkableA search paramBack and forward then behave as expected
A modal that should survive a refreshA search param or a nested routeIt becomes addressable
Draft text in an inputComponent stateIt is not something anyone would link to
Hover and focusComponent state or CSSIt 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 Response from a loader reaches the nearest errorElement, 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.

Custom hooks and sharing logic Server components, actions and the use API

Last refreshed 2026-09-18.