Skip to content

Route params and location

A list page and a detail page are the classic pair in a routed app: /vans shows every van, and clicking one should open /vans/2 with that van's details. Writing a separate route for every van would mean touching the router each time the data changes. Route params solve this with one route definition that matches them all: a placeholder in the path captures whatever value appears there, and the page reads that value to fetch the right data. This chapter covers that pattern, plus a related trick: passing extra information along with a navigation, so a detail page can remember things like which filter the list had applied.

Dynamic segments and useParams

A dynamic segment is a path section that starts with a colon. Instead of matching literal text, it matches anything in that position and saves the value under the name you chose:

jsx
<Route path="/vans/:id" element={<VanDetail />} />

Now /vans/1, /vans/42, and /vans/anything all render VanDetail. The colon marks id as a variable inside the path. Think of it like a function parameter: the route definition is written once, and the URL supplies the argument. The list page links each item to its own URL with the Link component from Routing, usually by interpolating the id while mapping over the data:

jsx
{vans.map(van => (
  <Link key={van.id} to={`/vans/${van.id}`}>
    <h3>{van.name}</h3>
  </Link>
))}

On the other side, the useParams hook returns an object with one property per dynamic segment in the matched path, keyed by the name after the colon:

jsx
import { useParams } from 'react-router-dom'

export default function VanDetail() {
  const params = useParams()
  // on /vans/2 → { id: "2" }
  return <h1>Van #{params.id}</h1>
}

A path can hold several segments, like /vans/:id/:type, and each shows up as its own key. A matched param is always a string, even when it looks like a number, because it comes straight out of the URL.

Fetching data for the param

The param is usually the key to a fetch. The detail page grabs the id from the URL and requests that one record, following the effect-based pattern that Fetching data covers in full:

jsx
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'

export default function VanDetail() {
  const { id } = useParams()
  const [van, setVan] = useState(null)
  const [error, setError] = useState(null)

  useEffect(() => {
    let active = true
    setVan(null)
    setError(null)
    fetch(`/api/vans/${id}`)
      .then(res => {
        if (!res.ok) throw new Error(`Request failed: ${res.status}`)
        return res.json()
      })
      .then(data => { if (active) setVan(data.vans) })
      .catch(err => { if (active) setError(err) })
    return () => { active = false }
  }, [id])

  if (error) return <h2>Sorry, we couldn't load that van.</h2>
  return van ? <h1>{van.name}</h1> : <h2>Loading...</h2>
}

The dependency array holds id rather than staying empty. If the app ever links from one detail page to another, say a "similar vans" section, React Router swaps the URL and the param without unmounting the component. An empty array would skip the refetch and leave the old van on screen. Depending on id reruns the effect, and clearing van at the top of the run is the half that gets "Loading..." back on screen: without it, van 2's name keeps rendering for the whole duration of van 5's request.

The res.ok check, the catch, and the active cleanup flag are the same guards Fetching data argues for, and a param-driven page needs them more than most: the id arrives from an address bar anyone can edit, and clicking quickly through detail pages is how a response for the van you already left arrives after the one you are looking at.

Params carry data from the URL into a page. Sometimes a page also wants to know something about where the visitor came from. In the course's VanLife project, the list page can be filtered down to, say, only luxury vans. Click into one, hit "Back to all vans", and a plain back link lands on the unfiltered list: the filter is lost, which gets annoying fast once several filters are involved.

One fix would be copying the query string into the detail page's URL, and Search params covers when URL-borne state like that is the right call: it survives sharing the link with someone else. When the information is a UX nicety for the current visitor, React Router offers a lighter channel. Link accepts a state prop, and whatever you pass rides along with the navigation without appearing in the URL:

jsx
<Link
  to={`/vans/${van.id}`}
  state={{ search: `?${searchParams.toString()}`, type: typeFilter }}
>
  <h3>{van.name}</h3>
</Link>

searchParams and typeFilter are the list page's own useSearchParams values, from the hook Search params covers in full next, so the link carries the full query string plus the current filter name. Any serializable value works, though an object with named properties reads best.

Reading it with useLocation

The destination page reads that state with the useLocation hook, which returns an object describing the current location: pathname, search (the current URL's own query string), and state, which holds whatever the incoming Link passed:

jsx
import { Link, useLocation } from 'react-router-dom'

export default function VanDetail() {
  const location = useLocation()
  const search = location.state?.search || ''
  const type = location.state?.type || 'all'

  return (
    <Link to={`..${search}`} relative="path">
      &larr; Back to {type} vans
    </Link>
  )
}

The two fallbacks are the important part. Someone who lands on /vans/2 directly, from a bookmark, a shared link, or a typed URL, arrives with location.state set to null because no Link sent them. Reading location.state.search would then throw, so the optional chaining plus a fallback keeps the page working: the back link goes to the plain list and the text reads "Back to all vans". With state present, the link restores the exact query string and the text becomes "Back to luxury vans" or whatever the filter was.

The relative="path" prop makes .. climb one URL segment instead of one level in the route tree, which matters when the detail route has a different parent than its URL suggests. Nested routes explains that distinction.

The sad path on a param-driven page

The course frames error handling as happy path versus sad path: the happy path assumes every request succeeds, the sad path plans for the ones that don't. The loading and error mechanics themselves belong to Fetching data: loading derived from the result and the error both still being empty, an error state set in the catch, and early returns that render feedback instead of crashing on missing data.

What this chapter adds is that param-driven pages multiply the sad paths. The URL is user-editable input, so /vans/999 is one keystroke away and the server will answer with a 404 for an id that doesn't exist; the res.ok check above is what turns that response into error state and a message rather than a van-shaped object. And as covered above, navigation state can be absent. A page that reaches the network through a param should assume the param can be wrong, the request can fail, and the state can be null, and render something sensible in each case.

Version note

Before React Router v5.1, route information arrived only as props: components read props.match.params and props.location. Version 5.1 added the useParams and useLocation hooks, and version 6 removed the prop-based API, leaving the hooks as the only way in. Any component under the router can call them, however deep it renders.

Link state is a thin wrapper over the browser's History API. Each history entry can carry a state value via history.pushState, and React Router stores your state prop there when the navigation happens. That mechanism explains how it behaves: under BrowserRouter the value survives a page refresh and the back and forward buttons, because it lives on the history entry itself and the browser persists those. It vanishes when the URL travels, because nothing about it is encoded in the address: paste the link into another browser and location.state is null.

That gives a clean decision rule for where per-navigation data belongs. Anything that should survive sharing, bookmarking, or a fresh visit goes in the URL as a search param. Anything that is a courtesy for the current visitor's session, like restoring a filter behind a back button, fits history state. Either way, treat location.state as untrusted and possibly null at every read site; the direct-landing case is a certainty at scale.

Params deserve the same skepticism plus two mechanical notes. First, a segment that matched arrives as a string, while an optional segment like /vans/:id? that matched nothing arrives as undefined (a splat that matched nothing gives an empty string instead), which is why the TypeScript type is string | undefined. A string id compared with === against a number silently fails, so convert at the boundary: Number(id) === van.id.

Second, a param change re-renders the mounted component instead of remounting it, which is why the fetch effect must list the param in its dependencies, and why any derived state initialized from a param needs resetting when it changes.

The router also matches static segments before dynamic ones, so /vans/new can coexist with /vans/:id: React Router ranks route specificity rather than taking definition order, which keeps a literal route from being swallowed by a neighboring param.

JunoOne route, many pages A colon in a route path, like /vans/:id, creates a blank that any value can fill, so one route serves a detail page for every item in your list. Inside the page, useParams hands you that value so you can fetch the right data.

Links can also carry a little package of extra information through their state prop, and useLocation reads it on the other side. Remember the package might be missing if someone arrived from a bookmark, so always have a fallback ready.

JunoOne route, many pages Define path="/vans/:id" once, read the id with useParams, and put it in your fetch effect's dependency array so navigating between detail pages refetches.

To keep a filter alive behind a back button, pass the query string in the Link state prop and read it via location.state with optional chaining and a fallback, because direct landings arrive with state as null.

Handle loading, errors, and bad ids the same way as any fetch, with the URL treated as user input.

JunoOne route, many pages Link state rides on the history entry through pushState, so it survives refresh and back navigation but never crosses browsers or shared URLs; put shareable data in search params and reserve history state for session-local courtesies.

A matched param is a string, and a param change re-renders without remounting, so effects must depend on it. Route matching ranks specificity, letting static segments like /vans/new coexist safely with /vans/:id.

Next up: Search params, where filters and sort orders live in the URL.