React 60 ⚛️ Dynamic Route Parameters and Search Params
A URL carries two kinds of information. The path identifies a resource: /teams/firebirds names a specific team. The query string carries options: /teams/firebirds?tab=roster&sort=name names the same team but asks for a particular view of it. React Router exposes the first through useParams and the second through useSearchParams. The two are separate hooks because they serve separate purposes, and treating them as interchangeable is the source of most routing bugs. The path is the identity of the resource, and the query string is the configuration of the view.
Key point: Route parameters are part of the URL path and identify which resource is being shown. Search parameters are part of the query string and identify how the resource is shown. A change to the path means a different resource. A change to the query string means a different view of the same resource.
Why the distinction matters
The identity problem. A route param is a value captured from a path segment. /teams/:teamId captures firebirds from /teams/firebirds. The value is read-only in the sense that the component cannot change it directly; only navigation changes it. The param identifies the resource.
The configuration problem. A search param is a key-value pair in the query string. ?tab=roster means the page should show the roster tab. The component can change the search params without navigating to a different route, because the route is the same; only the view configuration changes.
The shareability problem. Both kinds of information are in the URL, which means both are shareable and bookmarkable. A user who copies /teams/firebirds?tab=roster and sends it to a colleague expects the colleague to see the same page with the same tab open. The URL is the complete state of the view.
The history problem. Each navigation, whether it changes the path or the query string, adds an entry to the browser’s history. The back button returns to the previous URL, which restores the previous view. This works because the URL carries all the state that the router needs.
The validation problem. URL values are strings, and they are user-controlled. A teamId from the URL must be validated against the list of real teams. A page number from the query string must be parsed and bounds-checked. The URL is untrusted input, and the component must treat it as such.
The server problem. The query string is sent to the server on the initial request, but the client-side router parses it on navigation. The server does not need to handle it unless the application uses server-side rendering. For a purely client-side application, the query string is the router’s responsibility.
a. Dynamic route parameters
A dynamic route parameter is a path segment that starts with :. The segment matches any value in that position, and the value is captured and made available to the route’s element.
The route definition
<Route path="teams/:teamId" element={<TeamDetail />} />
The :teamId segment matches any value. The URL /teams/firebirds matches with teamId equal to "firebirds". The URL /teams/dolphins matches with teamId equal to "dolphins".
The useParams hook
The useParams hook returns an object with the captured values.
import { useParams } from 'react-router-dom';
function TeamDetail() {
const { teamId } = useParams();
return <h1>Team: {teamId}</h1>;
}
The keys of the object are the parameter names from the path. The values are the decoded URL segments.
The multiple parameters
A path can capture multiple parameters.
<Route path="teams/:teamId/players/:playerId" element={<PlayerDetail />} />
function PlayerDetail() {
const { teamId, playerId } = useParams();
return <h1>{playerId} plays for {teamId}</h1>;
}
The URL /teams/firebirds/players/42 captures teamId="firebirds" and playerId="42".
The optional parameters
A parameter can be optional by appending ? to the segment.
<Route path="teams/:teamId/:tab?" element={<TeamDetail />} />
The URL /teams/firebirds matches with teamId="firebirds" and no tab. The URL /teams/firebirds/roster matches with tab="roster".
The splat parameter
A splat captures the remaining path segments.
<Route path="files/*" element={<FileViewer />} />
function FileViewer() {
const { '*': splat } = useParams();
return <p>File: {splat}</p>;
}
The URL /files/docs/report.pdf captures splat="docs/report.pdf".
The parameter types
Every parameter is a string. A numeric ID must be parsed before use.
function PlayerDetail() {
const { playerId } = useParams();
const id = Number(playerId);
if (Number.isNaN(id)) return <NotFound />;
return <Player id={id} />;
}
The parsing and validation are the component’s responsibility. The router does not enforce types.
The parent parameters
A child route can read its parent’s parameters. The useParams hook returns all parameters from the matched route chain, merged into one object.
<Route path="teams/:teamId" element={<TeamsLayout />}>
<Route path="players/:playerId" element={<PlayerDetail />} />
</Route>
function PlayerDetail() {
const { teamId, playerId } = useParams();
// Both are available.
}
The child route sees the parent’s teamId and its own playerId.
The parameter change
When the parameter changes, React Router re-renders the route’s element. The component does not remount; it re-renders with the new params. This matters for effects that depend on the parameter.
function TeamDetail() {
const { teamId } = useParams();
useEffect(() => {
fetchTeam(teamId);
}, [teamId]);
// The effect reruns when teamId changes.
}
If the effect did not depend on teamId, it would not rerun, and the component would show stale data.
b. Search parameters
Search parameters are the key-value pairs in the query string. React Router exposes them through the useSearchParams hook, which returns a URLSearchParams object and a setter.
The useSearchParams hook
import { useSearchParams } from 'react-router-dom';
function TeamDetail() {
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') ?? 'overview';
return <div>Showing: {tab}</div>;
}
The searchParams object has the methods of URLSearchParams: get, getAll, has, entries, keys, and values.
The get method
get returns the first value for a key, or null if the key is absent.
const page = searchParams.get('page') ?? '1';
const sort = searchParams.get('sort') ?? 'name';
The ?? operator provides a default when the key is absent.
The getAll method
getAll returns all values for a key, which is useful for repeated parameters.
// URL: ?tag=react&tag=router
const tags = searchParams.getAll('tag'); // ['react', 'router']
The has method
has returns a boolean indicating whether the key is present.
if (searchParams.has('debug')) {
// Enable debug mode.
}
The set method
The setSearchParams function updates the query string. It accepts a URLSearchParams object, a string, or an object.
function setTab(tab) {
setSearchParams({ tab });
}
The call updates the URL and triggers a re-render. The route does not change, because the path is the same.
The merge behavior
The setSearchParams function replaces the entire query string by default. To merge with the existing parameters, the setter accepts a function that receives the current params.
function setPage(page) {
setSearchParams((prev) => {
prev.set('page', page);
return prev;
});
}
The function receives the current URLSearchParams object, modifies it, and returns it. The other parameters are preserved.
The delete method
The delete method removes a key from the query string.
setSearchParams((prev) => {
prev.delete('debug');
return prev;
});
The replace option
The setter accepts an options object with a replace flag. When replace: true, the navigation replaces the current history entry instead of adding a new one.
setSearchParams({ tab: 'roster' }, { replace: true });
This is useful for filter changes that should not clutter the history.
The array and object form
The setter accepts a plain object or an array of pairs.
setSearchParams({ tab: 'roster', sort: 'name' });
setSearchParams([['tab', 'roster'], ['sort', 'name']]);
The object form is the most readable for the common case.
The validation
Search parameters are strings and are user-controlled. A page parameter must be parsed and bounds-checked.
function parsePage(value) {
const n = Number(value);
if (!Number.isInteger(n) || n < 1) return 1;
return n;
}
const page = parsePage(searchParams.get('page'));
The component treats the parameter as untrusted input and validates it before use.
c. When to use which
The choice between a route parameter and a search parameter is a design decision. The rule is simple: the path identifies the resource, and the query string configures the view.
The resource identity
A value that identifies a specific resource belongs in the path.
| Value | Location | Reason |
|---|---|---|
| Team ID | Path | Identifies the team |
| Player ID | Path | Identifies the player |
| User ID | Path | Identifies the user |
| Document ID | Path | Identifies the document |
The view configuration
A value that configures how the resource is displayed belongs in the query string.
| Value | Location | Reason |
|---|---|---|
| Tab | Query | Selects the view |
| Page number | Query | Pagination |
| Sort order | Query | Ordering |
| Filter | Query | Subset |
| Search term | Query | Query |
The state preservation
A value that should be preserved across navigation to a related resource belongs in the query string. A user who filters a list and then clicks into a detail page expects the filter to be remembered when they return.
The server consideration
The query string is sent to the server on the initial request. A search term in the query string is available to the server for server-side rendering. A path parameter is also available, but only as part of the path.
The security consideration
Both the path and the query string are user-controlled. Neither should be trusted. The component must validate every value it reads, whether it comes from useParams or useSearchParams.
The default value
A search parameter that is absent should have a default. The component uses the default when the parameter is missing, so the URL can be shorter.
const tab = searchParams.get('tab') ?? 'overview';
const page = Number(searchParams.get('page') ?? '1');
The URL ?tab=overview&page=1 and the URL with no query string produce the same view. The default is the canonical form.
The canonical URL
When the component updates a search parameter, it should update the URL to match. This keeps the URL in sync with the view, so a copied URL shows the same view.
function setTab(tab) {
setSearchParams({ tab }, { replace: true });
}
The replace: true option keeps the history clean for filter changes that are not navigations.
Complete Example Session
// ============================================
// PART 1: THE ROUTE WITH A PARAMETER
// ============================================
<Route path="teams/:teamId" element={<TeamDetail />} />
// ============================================
// PART 2: READING THE PARAMETER
// ============================================
import { useParams } from 'react-router-dom';
function TeamDetail() {
const { teamId } = useParams();
return <h1>Team: {teamId}</h1>;
}
// ============================================
// PART 3: MULTIPLE PARAMETERS
// ============================================
<Route path="teams/:teamId/players/:playerId" element={<PlayerDetail />} />
function PlayerDetail() {
const { teamId, playerId } = useParams();
return <h1>Player {playerId} on {teamId}</h1>;
}
// ============================================
// PART 4: THE SPLAT PARAMETER
// ============================================
<Route path="files/*" element={<FileViewer />} />
function FileViewer() {
const { '*': splat } = useParams();
return <p>File: {splat}</p>;
}
// ============================================
// PART 5: READING SEARCH PARAMS
// ============================================
import { useSearchParams } from 'react-router-dom';
function TeamDetailWithTabs() {
const { teamId } = useParams();
const [searchParams] = useSearchParams();
const tab = searchParams.get('tab') ?? 'overview';
return (
<div>
<h1>Team: {teamId}</h1>
<p>Tab: {tab}</p>
</div>
);
}
// ============================================
// PART 6: UPDATING SEARCH PARAMS
// ============================================
function TeamTabs() {
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') ?? 'overview';
function selectTab(next) {
setSearchParams({ tab: next });
}
return (
<nav>
<button onClick={() => selectTab('overview')}>Overview</button>
<button onClick={() => selectTab('roster')}>Roster</button>
<button onClick={() => selectTab('stats')}>Stats</button>
</nav>
);
}
// ============================================
// PART 7: MERGING SEARCH PARAMS
// ============================================
function FilteredList() {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get('page') ?? '1');
const sort = searchParams.get('sort') ?? 'name';
function setPage(next) {
setSearchParams((prev) => {
prev.set('page', String(next));
return prev;
});
}
return (
<div>
<p>Page {page}, sorted by {sort}</p>
<button onClick={() => setPage(page + 1)}>Next</button>
</div>
);
}
// ============================================
// PART 8: VALIDATING PARAMETERS
// ============================================
function parsePage(value) {
const n = Number(value);
if (!Number.isInteger(n) || n < 1) return 1;
return n;
}
function SafeList() {
const [searchParams] = useSearchParams();
const page = parsePage(searchParams.get('page'));
return <p>Page {page}</p>;
}
// ============================================
// PART 9: THE REPLACE OPTION
// ============================================
function SearchBar() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get('q') ?? '';
function handleChange(e) {
setSearchParams({ q: e.target.value }, { replace: true });
}
return <input value={query} onChange={handleChange} />;
}
// Each keystroke replaces the history entry.
// The back button does not step through every character.
// ============================================
// PART 10: THE COMPLETE PATTERN
// ============================================
function TeamPage() {
const { teamId } = useParams();
const [searchParams, setSearchParams] = useSearchParams();
const tab = searchParams.get('tab') ?? 'overview';
const page = parsePage(searchParams.get('page'));
function selectTab(next) {
setSearchParams({ tab: next }, { replace: true });
}
return (
<div>
<h1>Team: {teamId}</h1>
<nav>
<button onClick={() => selectTab('overview')}>Overview</button>
<button onClick={() => selectTab('roster')}>Roster</button>
</nav>
{tab === 'overview' && <Overview teamId={teamId} />}
{tab === 'roster' && <Roster teamId={teamId} page={page} />}
</div>
);
}
// The path identifies the team.
// The query string configures the view.
// The URL is the complete state.
The ten parts showed the route with a parameter, reading the parameter, multiple parameters, the splat parameter, reading search params, updating search params, merging search params, validating parameters, the replace option, and the complete pattern.
Quick Reference
useParams
| Aspect | Value |
|---|---|
| Returns | Object of captured path parameters |
| Values | Strings |
| Includes parent params | Yes |
| Splat key | '*' |
| Re-renders on change | Yes |
useSearchParams
| Method | Purpose |
|---|---|
searchParams.get(key) | First value or null |
searchParams.getAll(key) | All values |
searchParams.has(key) | Presence check |
setSearchParams(obj) | Replace the query string |
setSearchParams(fn) | Merge with current |
setSearchParams(obj, { replace: true }) | Replace history entry |
Path vs Query
| Value | Location | Reason |
|---|---|---|
| Resource ID | Path | Identifies the resource |
| Tab | Query | Configures the view |
| Page | Query | Pagination |
| Sort | Query | Ordering |
| Filter | Query | Subset |
| Search term | Query | Query |
Parameter Types
| Syntax | Meaning |
|---|---|
:id | Required parameter |
:id? | Optional parameter |
* | Splat (rest of path) |
Navigation Options
| Option | Effect |
|---|---|
replace: true | Replace history entry |
state: {...} | Pass state |
preventScrollReset | Keep scroll position |
Best Practices
✅ Do This:
// Use useParams for resource identity.
const { teamId } = useParams(); // ✅
// Use useSearchParams for view configuration.
const [searchParams] = useSearchParams(); // ✅
// Validate every URL value.
const page = parsePage(searchParams.get('page')); // ✅
// Provide defaults for absent params.
const tab = searchParams.get('tab') ?? 'overview'; // ✅
// Merge when updating a single param.
setSearchParams((prev) => { prev.set('page', '2'); return prev; }); // ✅
// Use replace for filter changes.
setSearchParams({ q }, { replace: true }); // ✅
// Depend on params in effects.
useEffect(() => { fetchTeam(teamId); }, [teamId]); // ✅
❌ Don’t Do This:
// Don't put view configuration in the path.
<Route path="teams/:teamId/:tab" /> // ❌ Tab is view config
// Don't trust URL values.
const page = Number(searchParams.get('page')); // ❌ No validation
// Don't replace the entire query string when merging.
setSearchParams({ page: '2' }); // ❌ Drops other params
// Don't forget the dependency array.
useEffect(() => { fetchTeam(teamId); }, []); // ❌ Stale on param change
// Don't use search params for resource identity.
// /teams?id=firebirds // ❌ ID belongs in the path
// Don't ignore the replace option for filters.
// Each keystroke adds a history entry. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Param is undefined | Route not matched | Check the route definition |
| Value is a string | URL values are strings | Parse and validate |
| Effect shows stale data | Missing teamId in deps | Add it to the dependency array |
| Query string replaced | setSearchParams with an object | Use the function form |
| History cluttered | No replace: true | Add the option for filters |
| Parent param missing | Child route defined separately | Define the child inside the parent |
| Splat is undefined | Route has no * | Add the splat to the path |
Real-World Examples
1. Route Parameter
<Route path="teams/:teamId" element={<TeamDetail />} />
2. Read the Parameter
const { teamId } = useParams();
3. Multiple Parameters
const { teamId, playerId } = useParams();
4. Optional Parameter
<Route path="teams/:teamId/:tab?" element={<Team />} />
5. Splat Parameter
const { '*': splat } = useParams();
6. Read Search Param
const [searchParams] = useSearchParams();
const tab = searchParams.get('tab') ?? 'overview';
7. Update Search Param
setSearchParams({ tab: 'roster' });
8. Merge Search Params
setSearchParams((prev) => { prev.set('page', '2'); return prev; });
9. Replace History
setSearchParams({ q }, { replace: true });
10. Validate Param
const page = parsePage(searchParams.get('page'));
Visual
Path vs Query
┌────────────────────────────────────────────────────────────┐
│ URL ANATOMY │
│ │
│ https://app.example.com/teams/firebirds?tab=roster&page=2 │
│ ─────────┬───────────── ─────┬─────── ─────────┬───────── │
│ │ │ │ │
│ │ │ │ │
│ │ │ └─ QUERY │
│ │ │ tab, │
│ │ │ page │
│ │ │ │
│ │ └─ PATH PARAM │
│ │ teamId = "firebirds" │
│ │ │
│ └─ ORIGIN │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Path: which resource? → useParams │ │
│ │ Query: how to view it? → useSearchParams │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────┘
useParams Flow
┌────────────────────────────────────────────────────────────┐
│ useParams │
│ │
│ Route: teams/:teamId/players/:playerId │
│ URL: /teams/firebirds/players/42 │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ useParams() returns: │ │
│ │ { │ │
│ │ teamId: 'firebirds', │ │
│ │ playerId: '42' │ │
│ │ } │ │
│ │ │ │
│ │ All values are strings. │ │
│ │ Parent params are included. │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ Validate: │
│ const id = Number(playerId); │
│ if (Number.isNaN(id)) return <NotFound />; │
└────────────────────────────────────────────────────────────┘
useSearchParams Flow
┌────────────────────────────────────────────────────────────┐
│ useSearchParams │
│ │
│ URL: /teams/firebirds?tab=roster&page=2 │
│ │
│ const [searchParams, setSearchParams] = useSearchParams() │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ searchParams.get('tab') → 'roster' │ │
│ │ searchParams.get('page') → '2' │ │
│ │ searchParams.get('sort') → null │ │
│ │ │ │
│ │ Defaults: │ │
│ │ const tab = searchParams.get('tab') ?? 'overview'│ │
│ │ const page = Number(searchParams.get('page') ?? '1')│
│ └──────────────────────────────────────────────────┘ │
│ │
│ Update: │
│ setSearchParams({ tab: 'stats' }) │
│ → /teams/firebirds?tab=stats (other params dropped) │
│ │
│ Merge: │
│ setSearchParams((prev) => { prev.set('tab', 'stats'); return prev })│
│ → /teams/firebirds?tab=stats&page=2 │
└────────────────────────────────────────────────────────────┘
Path vs Query Decision
┌────────────────────────────────────────────────────────────┐
│ DECISION GUIDE │
│ │
│ Is the value the identity of a resource? │
│ YES → Path parameter │
│ NO → Continue │
│ │
│ Is the value a configuration of the view? │
│ YES → Search parameter │
│ NO → Continue │
│ │
│ Should the value be preserved across navigation? │
│ YES → Search parameter │
│ NO → Continue │
│ │
│ Is the value sensitive? │
│ YES → Consider not putting it in the URL at all │
│ NO → Path or query, based on the above │
└────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
useParams | Read path parameters |
useSearchParams | Read and write query string |
| Path parameter | Identifies a resource |
| Search parameter | Configures the view |
:id | Required parameter |
:id? | Optional parameter |
* | Splat (rest of path) |
get | First value or null |
getAll | All values for a key |
setSearchParams | Update the query string |
replace: true | Replace history entry |
Key takeaways:
- Path parameters identify resources, and search parameters configure the view. The path
/teams/firebirdsnames a team. The query?tab=rosterasks for the roster tab of that team. The two are separate concerns, and the hooks reflect that. useParamsreturns the captured path values. The values are strings, and they include the parameters from the parent routes in the matched chain. A numeric parameter must be parsed and validated.useSearchParamsreturns aURLSearchParamsobject and a setter. The object hasget,getAll,has, and the rest. The setter updates the query string and triggers a re-render.- The setter replaces the query string by default. To merge a single parameter with the existing ones, pass a function that receives the current params, modifies them, and returns them.
- The
replaceoption keeps the history clean. A filter change that adds a history entry for every keystroke clutters the back button. Thereplace: trueoption replaces the entry instead of adding one. - URL values are user-controlled and must be validated. A
pageparameter that is not a number, or ateamIdthat does not exist, must be handled gracefully. The component treats the URL as untrusted input. - The URL is the complete state of the view. A copied URL reproduces the same page, because both the path and the query string are in the URL. This is the reason for putting view configuration in the query string rather than in component state.
- Effects must depend on the parameters. An effect that fetches data based on a
teamIdmust includeteamIdin its dependency array. Otherwise the effect does not rerun when the parameter changes, and the component shows stale data.
Remember: React Router gives the URL two channels for state: the path and the query string. The path identifies the resource, and the query string configures the view. The useParams hook reads the path parameters, and the useSearchParams hook reads and writes the query string. Both are strings, both are user-controlled, and both must be validated before use. The path parameter changes when the resource changes, and the component re-renders with the new value. The search parameter changes when the view changes, and the setter updates the URL without navigating to a different route. The replace option keeps filter changes out of the history. The result is a URL that is the complete state of the view, shareable and bookmarkable, with the path and the query string each serving their own purpose.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!