Command Palette

Search for a command to run...

Hectal
PHASE 13Intermediate ~33 min· topic 3 of 6

Topic 13.3

Dynamic Segments, Params and Search Params

In one line

A dynamic segment like /products/:productId lets one route serve every product and useParams() reads the value; useSearchParams() reads and writes the ?key=value part for filters and sorting; useNavigate() moves the user from code, and useLocation() tells you exactly where you are.

Think of it like this

A row of lockers numbered 1 to 500. The school doesn't write 500 different rules for 500 lockers. There is one rule: "locker number N belongs to the student on the list at line N." A dynamic segment is that one rule: /products/:productId handles /products/1, /products/2 and /products/500, and the number is handed to the page so it can show the right product.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Path segment
One piece of a path between slashes: /products/42 has the segments products and 42.
Dynamic segment
A segment in a route path that starts with : (:productId) and matches any value in that position.
URL params
The values matched by dynamic segments, read with useParams(). Always strings.
Optional segment
A segment ending in ? (:lang?) that may be missing from the URL.
Splat
A * at the end of a path that matches everything after it, read as params["*"].
URLSearchParams
The browser class for reading and building query strings: get, getAll, set, append, delete, toString.
navigate
The function from useNavigate() that changes the location from code.
Location state
An object attached to a history entry with navigate(to, { state }) or <Link state>. Not part of the URL.
replace
A navigation option that overwrites the current history entry instead of adding a new one.

Step by step

01One route for every product

Declare the dynamic segment in the path. Static routes next to it (/products/new) still win for their exact URL (Topic 13.2's ranking).

App.jsxwhole filejsx
<Routes>
  <Route path="/products" element={<ProductList />} />
  <Route path="/products/new" element={<NewProduct />} />
  <Route path="/products/:productId" element={<ProductPage />} />
  <Route path="/:lang?/docs/*" element={<Docs />} />
</Routes>

02Read and validate the param

useParams() returns strings. Convert, look the item up, and render a not-found message when it is missing. Links to the page build the URL from the id.

ProductPage.jsxwhole filejsx
function ProductPage() {
  const { productId } = useParams();
  const product = products.find((p) => p.id === Number(productId));
  if (!product) return <NotFound what="product" />;
  return <h1>{product.name}</h1>;
}

// elsewhere
<Link to={`/products/${product.id}`}>{product.name}</Link>

03Same component, new param

Going from /products/1 to /products/2 keeps the same ProductPage instance mounted: only its params change. State inside it (a quantity input, a selected tab) is kept, which is usually a bug. Effects that depend on productId re-run (Topic 7.5). To reset everything per product, give the element a key: <ProductPage key={productId} /> inside a small wrapper, the same trick as Topic 6.5.

Same component, new paramdiagram
Rendering diagram…

04Filters in search params

Read with get, give a default in code, and update one key with the function form so the others stay. Use replace for changes that shouldn't fill the history, like sort order or each keystroke in a search box.

Books.jsxwhole filejsx
const [searchParams, setSearchParams] = useSearchParams();
const genre = searchParams.get("genre") ?? "all";

setSearchParams((prev) => {
  const next = new URLSearchParams(prev);
  next.set("genre", "scifi");
  return next;
});

setSearchParams({ q: text }, { replace: true }); // replaces the whole query

05Navigate from code

Use navigate after something happens: a save, a logout, a step in a wizard. Pass replace: true when the current page should disappear from history (a login page after logging in), and state for one-time messages.

NewNote.jsxwhole filejsx
const navigate = useNavigate();

async function handleSubmit(e) {
  e.preventDefault();
  const note = await saveNote(text);
  navigate(`/notes/${note.id}`, { state: { flash: "Saved" } });
}

06Know where you are

useLocation() gives the full picture of the current entry. It changes identity on every navigation, so it is a good dependency for "do something on every page change" effects.

usePageView.jswhole filejsx
function usePageView() {
  const location = useLocation();
  useEffect(() => {
    analytics.track(location.pathname + location.search);
  }, [location]);
}

Try it yourself

  1. 1

    A third product

    Add { id: 3, name: "Notebook", price: 4 } to the products. Predict: do you need a new route? Then change Number(productId) back to productId and predict what every product page shows.

  2. 2

    Object form vs function form

    In the books example, replace the body of setParam with setSearchParams({ [key]: value });. Click Sci-fi, then Sort by year. Predict the address bar before you look. Why did the genre disappear?

  3. 3

    replace in navigate

    In the notes example, add replace: true to the navigate options. Predict where Back takes you now. When would that be the right behaviour?

Code & diagrams

useParams: one route, many products New tab

The param arrives as the string `"2"`, so the code converts it with `Number`. Users can type any URL, so `/products/99` shows a friendly not-found message instead of crashing on `product.name`.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

productId: "2" found: Desk lamp
productId: "99" found: nothing
useSearchParams: filters and sort in the URL New tab

The page starts from a deep link (`?genre=classic`). Each button changes **one** key and keeps the other. There is no `useState` for the filter: the list is derived from the URL on every render.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

/books?genre=classic -> Emma (1815), Persuasion (1817)
/books?genre=scifi -> Ancillary Justice (2013), Dune (1965), Hyperion (1989)
/books?genre=scifi&sort=year -> Dune (1965), Hyperion (1989), Ancillary Justice (2013)
/books?sort=year -> Emma (1815), Persuasion (1817), Dune (1965), Hyperion (1989), Ancillary Justice (2013)
useNavigate and useLocation: go somewhere after saving New tab

`location.search` and `location.hash` are raw strings including `?` and `#`. The flash message travels in `state`, which isn't in the URL. `navigate(-1)` works like the browser's Back button.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

pathname: /notes/7 | search: ?view=full | hash: #top | state: { flash: 'Saved: Buy milk' }
after Back: /notes/new
What each hook returns on /shop/12?color=red#reviewstsx
// <Route path="/shop/:id" element={<Item />} />
useParams();                     // { id: "12" }
const [sp] = useSearchParams();
sp.get("color");                 // "red"
sp.get("size");                  // null
useLocation();
// { pathname: "/shop/12", search: "?color=red", hash: "#reviews", state: null, key: "k3j2x9" }
const navigate = useNavigate();
navigate("..");                  // relative: up one route level (Topic 13.4)
navigate(-1);                    // back one history entry

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Compare a string param with a number id

In ProductPage, write products.find((p) => p.id === productId) (no Number).

terminal
$ Console and page
── what you'll see ──
productId: "2" found: nothing
(page) No product with id 2.

Break #2

Copy search params into state

Write const [genre, setGenre] = useState(searchParams.get("genre")); and use genre for the filter, calling both setGenre and setSearchParams in the buttons. Click Sci-fi, then press the browser's Back button.

terminal
$ Page
── what you'll see ──
Address bar: /books?genre=classic
List: Ancillary Justice, Dune, Hyperion (still sci-fi)

Myth vs fact

Myth

useParams gives numbers when the URL has numbers.

Fact

Every param is a string, because URLs are text. Convert and validate yourself.

Myth

setSearchParams({ sort: 'year' }) adds sort to the existing query.

Fact

The object form replaces the whole query string. Use the function form (copy prev, change one key) to keep the others.

Myth

navigate is the normal way to make navigation links.

Fact

Links should be <Link>, a real anchor with an href. navigate is for navigation that happens because of something else: a submit, a timer, a logout.

Myth

Location state is a safe place to pass data to the next page.

Fact

It is lost when the link is copied, opened in a new tab or bookmarked. Use it only for nice-to-have extras, like a flash message or a "from" path.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    React Router's generatePath("/products/:id", { id }) builds paths from patterns, and matchPath("/products/:id", pathname) tests a path outside of rendering. Both encode/decode params; since 8.3, href/generatePath follow RFC 3986 path rules, so characters like + and : are no longer percent-encoded.

  • ▸

    useSearchParams writes go through navigate, so each call is a navigation. Unlike React's state updaters, two calls in one handler are not queued and merged: the docs warn that the function form does not support queueing, so the second navigation can overwrite the first. Make related changes in one setSearchParams call.

  • ▸

    For a search box, keep the text being typed in local state and push to the URL debounced or on submit (Topic 10.2's useDebounce), with replace: true. Every keystroke as a navigation is slow on big pages and clutters history.

  • ▸

    When the same component stays mounted across param changes, any data fetch keyed on the param needs a cleanup to ignore stale responses (Topic 7.6). Data routers (Topic 13.5) and TanStack Query (Topic 14.6) handle this race for you.

Remember this

  1. 1

    A dynamic segment is a part of a route's path that starts with a colon, such as :productId in path="/products/:productId". It matches one whole segment (the text between two /), whatever it is: /products/42 and /products/blue-mug both match, /products/42/reviews does not. Inside the route's component, **useParams()** returns an object of all matched segments: { productId: "42" }. A path can have several (/users/:userId/posts/:postId). Add ? to make a segment optional (/:lang?/about matches /about and /en/about). A splat * at the end matches the rest of the path, available as params["*"] (for example /docs/* on /docs/hooks/use-state gives "hooks/use-state").

  2. 2

    Params are always strings. The URL is text, so useParams() gives "42", never 42. Comparing p.id === productId with a number id is false and the page says "not found" for a product that exists. Convert on purpose: Number(productId), and handle values that aren't valid (Number("abc") is NaN) and ids that don't exist, because users can type any URL. A page reached by a param must always have a "not found" branch. Params are also decoded for you: /search/caf%C3%A9 gives "café".

  3. 3

    **useSearchParams()** returns [searchParams, setSearchParams], like useState for the query string. searchParams is a standard URLSearchParams: get("genre") returns a string or null when missing, getAll("tag") returns every value of a repeated key, has("q") checks presence. setSearchParams navigates to a new query string: pass an object ({ genre: "scifi" }), a URLSearchParams, or a function that receives the current params and returns new ones. The object form replaces the whole query string, dropping other keys; to change one key and keep the rest, copy the previous params in the function form. The second argument takes navigation options such as { replace: true }, which updates the current history entry instead of adding one, so typing in a search box doesn't create fifty Back-button stops.

  4. 4

    Treat search params as state you don't own a copy of. Read the filter straight from searchParams on every render and derive the list from it. Don't copy it into useState and sync with an effect: you'd have two sources of truth that drift apart when the user presses Back (Topic 6.2 on avoiding redundant state). Provide defaults in code (searchParams.get("sort") ?? "title") rather than writing them into the URL, so clean URLs stay clean.

  5. 5

    **useNavigate()** returns a navigate function for moving the user from code: after a form is saved, after logging out, when a timer ends. navigate("/notes/7") goes to a path; navigate("/login", { replace: true }) replaces the current entry (good for redirects); navigate("/notes/7", { state: { flash: "Saved" } }) attaches invisible data; navigate(-1) goes back one entry, like the browser's Back button (navigate(1) goes forward). Call it in event handlers or effects, never during render. For a link the user clicks, prefer <Link>: it is a real link with an href, which navigate in a button's onClick is not.

  6. 6

    **useLocation() returns the current location object**: pathname ("/notes/7"), search ("?view=full", the raw string), hash ("#top"), state (whatever was passed with navigate or <Link state>, null otherwise) and key (a unique id for this history entry). Components that call it re-render on every navigation. Use it for things like analytics on page change, a "from" path for redirects (Topic 13.6), or an address bar like the one in these examples. Location state is not in the URL: it survives Back and Forward in the same tab, but a copied link or a new tab won't have it, so never store anything the page truly needs there.

Explain it without notes

01

What is a dynamic segment, and what does useParams() return for it?

02

Why must you validate params, and what are the usual mistakes?

03

How does useSearchParams work, and why should filters live there instead of in useState?

04

When do you use useNavigate instead of <Link>, and what do replace and state do?

Practice

01

Create a route /users/:userId/posts/:postId and a component that prints both params, with links to /users/1/posts/10 and /users/2/posts/20.

02

Build a search page where typing in an input updates ?q= with replace: true and the list shows only fruit names containing q.

03

After a fake login button is clicked, navigate to /dashboard with replace: true and show Welcome back from location state.

Trade-offs

  • ↔

    Path params are for identity (which item); search params for presentation (filters, sort, page). Mixing them up gives URLs like /books/scifi/year/2 that are hard to extend, or /item?id=7 that can't use nested routes.

  • ↔

    replace: true keeps history clean but means Back can't undo that step. Use push for steps a user would want to go back to (opening an item) and replace for refinements (sort, typing).

Done when you can

  • I can declare dynamic, optional and splat segments and read them with useParams.

  • I convert and validate params and always render a not-found state.

  • I can read and update one search param while keeping the others.

  • I derive UI from search params instead of copying them into state.

  • I can navigate from code with replace, state and -1.

  • I can read pathname, search, hash and state from useLocation.