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/42has the segmentsproductsand42. - 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 asparams["*"]. - 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).
<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.
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.
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.
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 query05Navigate 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.
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.
function usePageView() {
const location = useLocation();
useEffect(() => {
analytics.track(location.pathname + location.search);
}, [location]);
}Try it yourself
- 1
A third product
Add
{ id: 3, name: "Notebook", price: 4 }to the products. Predict: do you need a new route? Then changeNumber(productId)back toproductIdand predict what every product page shows. - 2
Object form vs function form
In the books example, replace the body of
setParamwithsetSearchParams({ [key]: value });. Click Sci-fi, then Sort by year. Predict the address bar before you look. Why did the genre disappear? - 3
replace in navigate
In the notes example, add
replace: trueto thenavigateoptions. Predict where Back takes you now. When would that be the right behaviour?
Code & diagrams
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
Console
Nothing logged yet.Expected console output
productId: "2" found: Desk lamp
productId: "99" found: nothingThe 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
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)`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
Console
Nothing logged yet.Expected console output
pathname: /notes/7 | search: ?view=full | hash: #top | state: { flash: 'Saved: Buy milk' }
after Back: /notes/new// <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 entryBreak 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).
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.
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, andmatchPath("/products/:id", pathname)tests a path outside of rendering. Both encode/decode params; since 8.3,href/generatePathfollow RFC 3986 path rules, so characters like+and:are no longer percent-encoded. - ▸
useSearchParamswrites go throughnavigate, 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 onesetSearchParamscall. - ▸
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), withreplace: 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
A dynamic segment is a part of a route's path that starts with a colon, such as
:productIdinpath="/products/:productId". It matches one whole segment (the text between two/), whatever it is:/products/42and/products/blue-mugboth match,/products/42/reviewsdoes 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?/aboutmatches/aboutand/en/about). A splat*at the end matches the rest of the path, available asparams["*"](for example/docs/*on/docs/hooks/use-stategives"hooks/use-state"). - 2
Params are always strings. The URL is text, so
useParams()gives"42", never42. Comparingp.id === productIdwith a number id isfalseand the page says "not found" for a product that exists. Convert on purpose:Number(productId), and handle values that aren't valid (Number("abc")isNaN) 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%A9gives"café". - 3
**
useSearchParams()** returns[searchParams, setSearchParams], likeuseStatefor the query string.searchParamsis a standardURLSearchParams:get("genre")returns a string ornullwhen missing,getAll("tag")returns every value of a repeated key,has("q")checks presence.setSearchParamsnavigates to a new query string: pass an object ({ genre: "scifi" }), aURLSearchParams, 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
Treat search params as state you don't own a copy of. Read the filter straight from
searchParamson every render and derive the list from it. Don't copy it intouseStateand 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
**
useNavigate()** returns anavigatefunction 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 anhref, whichnavigatein a button'sonClickis not. - 6
**
useLocation()returns the current location object**:pathname("/notes/7"),search("?view=full", the raw string),hash("#top"),state(whatever was passed withnavigateor<Link state>,nullotherwise) andkey(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. Locationstateis 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
What is a dynamic segment, and what does useParams() return for it?
Why must you validate params, and what are the usual mistakes?
How does useSearchParams work, and why should filters live there instead of in useState?
When do you use useNavigate instead of <Link>, and what do replace and state do?
Practice
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.
Build a search page where typing in an input updates ?q= with replace: true and the list shows only fruit names containing q.
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/2that are hard to extend, or/item?id=7that can't use nested routes. - ↔
replace: truekeeps 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,stateand-1.I can read
pathname,search,hashandstatefromuseLocation.