Skip to content

Search params

Route params tell your app which page it's on. Search params, also called query params, describe how that page should look: which filter is active, what the list is sorted by, which page of results is showing. They live in the URL after a question mark, as key/value pairs like /vans?type=rugged, with extra pairs joined by ampersands: /vans?type=rugged&sort=price. Because they're part of the URL, they survive a refresh and travel inside a shared link, which makes them a different kind of state than anything useState can hold.

State that belongs in the URL

State kept with useState lives in memory. Refresh the page and it resets to its initial value; copy the URL to a friend and they get a fresh start with none of your choices. For a lot of state that's exactly right. For some of it, it's a real loss: if you've narrowed a list of vans down to the rugged ones under a certain price, you probably want a refresh to keep that view, and you want a pasted link to open the same curated list for someone else.

The course offers a useful test: should a user be able to revisit or share this page exactly as it is and get the same result? If yes, consider raising that piece of state out of React and into the URL as a search param. Filtering, sorting, and pagination are the classic candidates. The URL then becomes the single source of truth for that state, and your component derives what it renders from it, the same way it would derive from state or props.

Reading params with useSearchParams

React Router exposes the query string through the useSearchParams hook, and its shape is deliberately close to useState: an array holding the current value and a setter.

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

export default function CharacterList() {
  const [searchParams, setSearchParams] = useSearchParams()
  const typeFilter = searchParams.get('type')
  // ...
}

searchParams is an instance of the browser's native URLSearchParams object rather than a plain object, so you interact with it through its methods. .get('type') returns the value of the type param as a string, and returns null when that param isn't in the URL at all. That null is how your code knows no filter is active. .toString() serializes the whole set back into a query string like type=sith&sort=price, without the leading question mark.

Version note

The examples here import from react-router-dom, which works in both v6 and v7; Routing covers what v7 changed about the packages.

Filtering a list from a param

With the param in hand, filtering is plain JavaScript at the top of the component. No state, no effect: reading a param and filtering an array are both fast, so it's fine to redo the work on every render and let the result fall out of the URL.

jsx
const typeFilter = searchParams.get('type')

const displayedCharacters = typeFilter
  ? characters.filter(char => char.type.toLowerCase() === typeFilter.toLowerCase())
  : characters

const charEls = displayedCharacters.map(char => (
  <li key={char.name}>{char.name}</li>
))

The ternary handles the no-filter case: when .get returns null, the full list is shown. Choosing which array to render this way is the same derive-as-you-render move you've used for conditional rendering, driven by the URL instead of by state. Watch for casing mismatches too; the data stores "Sith" while a hand-edited link might carry Sith or sith, so both sides get lowercased before they're compared.

The most direct way to put a search param in the URL is a Link whose to starts with a question mark. React Router sees the leading ?, keeps you on the current route, and swaps in the new query string, which re-renders the component and re-runs your filtering.

jsx
<Link to="?type=jedi">Jedi</Link>
<Link to="?type=sith">Sith</Link>
<Link to=".">Clear</Link>

For clearing, to="." navigates to the current path with no query attached; to="" works as well, and the course settles on the dot as the more explicit of the two. Links fit best when the filter is a fixed set of visible choices: they render as real anchor tags, so users can open a filtered view in a new tab or copy the address before clicking.

Setting params with the setter function

The second element from useSearchParams is a setter, and like the one from useState it accepts either a replacement value or a callback. Since a button isn't part of React Router's ecosystem the way Link is, you call the setter from an ordinary event handler.

jsx
<button onClick={() => setSearchParams({ type: 'jedi' })}>Jedi</button>
<button onClick={() => setSearchParams({ type: 'sith' })}>Sith</button>
<button onClick={() => setSearchParams({})}>Clear</button>

The setter is flexible about what it takes: a string like '?type=jedi' (with or without the question mark) works, but the object form shown here is what you'll see most often, and an empty object clears everything. Reach for the setter when the new params come out of logic instead of a click on a fixed choice: reading values from a form, responding to input as the user types, or setting several params at once.

Replacing wipes the other params

Both approaches so far hard-code the entire query string. That's fine while type is the only param your app uses, but the moment the URL also carries something unrelated, say ?name=jill&type=jedi, clicking one of those links or buttons replaces the whole query string and name=jill is gone. The clear buttons are even blunter: they wipe every param in the URL, including ones this component never touched. If you're certain your project will only ever have the one param, hard-coding is fine. Otherwise you want to merge.

Merging with existing params

For Links, the to prop takes a string, so the fix is a small helper that runs during render: copy the current params into a fresh URLSearchParams, change the one key that's moving, and serialize the result. It lives inside the component, since it reads searchParams from the hook.

jsx
// inside CharacterList, so it can read searchParams
function genNewSearchParamString(key, value) {
  const sp = new URLSearchParams(searchParams)
  if (value === null) {
    sp.delete(key)
  } else {
    sp.set(key, value)
  }
  return `?${sp.toString()}`
}
jsx
<Link to={genNewSearchParamString('type', 'jedi')}>Jedi</Link>
<Link to={genNewSearchParamString('type', 'sith')}>Sith</Link>
<Link to={genNewSearchParamString('type', null)}>Clear</Link>

This is vanilla JavaScript rather than anything React Router provides. The URLSearchParams constructor happily accepts an existing params object as its starting point, .set updates or adds one key, and passing null signals the helper to .delete the key instead, so "Clear" now removes only type and leaves everything else in the URL intact.

For the setter, use its callback form. The callback receives the previous params object, you adjust the one key, and return it.

jsx
// inside CharacterList, so it can call setSearchParams
function handleFilterChange(key, value) {
  setSearchParams(prevParams => {
    if (value === null) {
      prevParams.delete(key)
    } else {
      prevParams.set(key, value)
    }
    return prevParams
  })
}
jsx
<button onClick={() => handleFilterChange('type', 'jedi')}>Jedi</button>
<button onClick={() => handleFilterChange('type', null)}>Clear</button>

One surprise the course calls out: unlike a useState updater, where mutating the previous state is forbidden, here it's fine to call .delete and .set directly on prevParams and return it. Now both the links and the buttons change only the param they own.

The mutation being safe is a consequence of what the setter actually does. When you dispatch a useState update, React compares the new value to the current one with Object.is before it schedules a re-render, so returning the same mutated object reads as no change and the update never lands. setSearchParams triggers a navigation instead: it serializes whatever you return into a new location and pushes it, with no identity comparison to defeat.

What the callback receives

Since React Router 7.7.0 you get a copy of the current params; earlier versions back to 6.4, where the callback form arrived, hand you the live instance the component is rendering with. Mutating what you're given and returning it works in either case.

The useState analogy also stops at queueing: two setSearchParams calls in the same handler both start from the current URL, so the second overwrites the first. Set several params in one call instead. Each set, like each Link click, pushes a history entry by default, so for params that change on every keystroke pass { replace: true } as the setter's second argument.

Two sharper edges show up in practice. First, a query string can legally repeat a key: ?type=jedi&type=sith. .get returns only the first value, .getAll returns them all as an array, and .delete(key) removes every entry for that key, so the merge helpers above collapse repeated keys rather than managing them individually. Multi-select filters need .getAll plus .append, and .delete(key, value) removes a single value while leaving the other entries for that key alone.

Second, resist mirroring params into state. Copying searchParams.get('type') into useState via an effect creates two sources of truth that drift out of sync for a render; deriving during render, as every example here does, keeps the URL as the only authority and costs one cheap recomputation.

JunoThe URL can hold your state Some state deserves to survive a refresh and travel in a shared link, like which filter is switched on. Search params keep that state in the URL after the question mark, and useSearchParams lets you read it: searchParams.get('type') returns the value, or null when the param isn't there.

You can set params with a Link to a query string or with the setter function, then filter your list from whatever the URL says.

JunoThe URL can hold your state Use the share test: if revisiting the link should reproduce the view, the state belongs in a search param.

Read it with useSearchParams, derive the filtered list at the top of the component, and set params with a Link for fixed visible choices or the setter for programmatic changes.

Hard-coding a full query string wipes unrelated params, so merge: build the Link string from a copy of the current params, or use the setter's callback form and adjust the one key you own.

JunoThe URL can hold your statesetSearchParams is a navigation rather than a state update, which is why mutating the previous URLSearchParams in the callback is safe and why every set pushes a history entry unless you pass replace: true.

Remember that keys can repeat, .get reads only the first and .delete removes them all, and keep the URL as the single authority by deriving during render instead of mirroring params into state.

Next up: Protected routes, where a branch of the app asks who you are first.