In React Router v7, why does setSearchParams({ page: "2" }) drop ?category and ?sort, and how do you change one param while keeping the rest?
answer
- the setter writes the whole query
- not a merge like setState objects
- the callback gets a copy
- set, delete, append, then return
basics
~20 ssetSearchParams replaces the entire query string with what you pass, so an object with only page discards everything else. Use the callback form: take the copy it receives, set or delete the one key, and return it.
solid answer
~40 s`useSearchParams()` returns `[searchParams, setSearchParams]`, where `searchParams` is a `URLSearchParams` for the current URL. `setSearchParams(next)` builds new params from `next` and navigates to `?` plus those params, so it **replaces** the whole query: `setSearchParams({ page: "2" })` on `?category=shoes&sort=price` leaves `?page=2`. To change one key, use the callback form: `setSearchParams(prev => { prev.set("page", "2"); return prev; })`. `prev` is a fresh copy of the current params, so mutating it is safe; `delete` removes a key and `append` adds another value for multi-valued keys. Each call is a navigation that pushes a history entry unless you pass `{ replace: true }` as the second argument. Never mutate `searchParams` directly: the object changes but the URL does not.
code
tsx · 23 linesimport { useSearchParams } from "react-router";
export function CategoryFilter() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get("category") ?? "";
function onChange(next: string) {
setSearchParams((prev) => {
if (next) prev.set("category", next);
else prev.delete("category");
prev.delete("page"); // back to page 1 in the same update
return prev; // sort and other keys survive
});
}
return (
<select value={category} onChange={(e) => onChange(e.target.value)}>
<option value="">All</option>
<option value="shoes">Shoes</option>
<option value="bags">Bags</option>
</select>
);
}go deeper
Recall that setSearchParams with an object replaces the whole query, and that the callback form lets you change one key while keeping the rest.
Explain that the setter navigates to a new query, that the callback gets a copy, the set/delete/append differences, and the push-by-default behaviour.
Design filter updates that reset pagination in the same call, keep URLs canonical, and choose replace for high-frequency changes.
Treat query-string keys as a public contract for shareable views: stable names, canonical defaults and one helper for updates across the app.
## The scenario A product list keeps its view in the query string: `/products?category=shoes&sort=price&page=1`. Keeping it there means a reload rebuilds the same view, the URL can be shared, and the browser's Back button walks through earlier filter states. React Router v7 exposes that query string through `useSearchParams`. ## What the hook returns ```tsx const [searchParams, setSearchParams] = useSearchParams(); searchParams.get("category"); // "shoes" or null ``` - `searchParams` is a standard **`URLSearchParams`** built from `location.search`. `get` returns a string or `null`, `getAll` returns every value of a repeated key. - The object is memoised on `location.search`, so it is a **stable reference** while the query is unchanged and is safe in effect dependency lists. - `setSearchParams(next, navigateOptions?)` accepts a string, an object (array values allowed), an array of tuples, a `URLSearchParams`, or a **function** returning any of these. ## Why the object form wipes other params `setSearchParams` does not merge. It converts `next` into a `URLSearchParams` and navigates to `"?" + next`, a search-only navigation that keeps the current pathname and **replaces the entire query**: | Current URL | Call | Result | |---|---|---| | `?category=shoes&sort=price` | `setSearchParams({ page: "2" })` | `?page=2` | | `?category=shoes&sort=price` | callback that sets `page` | `?category=shoes&sort=price&page=2` | | `?brand=nike` | callback that `append`s `brand=reebok` | `?brand=nike&brand=reebok` | | `?brand=nike` | callback that `set`s `brand=reebok` | `?brand=reebok` | The object form is right when you intend to define the whole query, such as a "clear filters" button that resets to one known state. ## The callback form ```tsx setSearchParams((prev) => { prev.set("page", "2"); return prev; }); ``` - `prev` is **a new `URLSearchParams` copied** from the current params, so mutating and returning it does not touch the object components are reading. - Use `set` to replace a key's value, `delete` to remove a key, and `append` to add another value to a multi-valued key such as selected brands. - Return the params; whatever you return becomes the entire new query. ## Every call is a navigation Because `setSearchParams` calls `navigate` under the hood: 1. By default it **pushes** a history entry, so the browser Back button undoes the last filter change. That is usually right for deliberate choices like category or page. 2. Pass navigation options as the second argument: `setSearchParams(fn, { replace: true })` for high-frequency updates such as typing in a search box, and `preventScrollReset: true` in a data router when a filter bar sits mid-page. 3. The component re-renders from the new URL. There is no separate state to keep in sync. ## Do not mutate the returned object `searchParams.set("page", "3")` on the object returned by the hook changes that object in memory but **does not change the URL**. The docs warn that such values then shift between renders while the URL still says otherwise. Always go through `setSearchParams`. ## Keeping URLs tidy - Delete keys that are back at their default (`page` 1, no category) so equivalent views share one canonical URL. - Reset `page` whenever a filter changes, in the **same** callback, so users are not left on page 7 of a shorter list. - Parse on read: `Number(searchParams.get("page") ?? "1")`, then validate, since query values are strings typed by users. ## Reading values safely Every value read from the query is a string typed, pasted or edited by someone, so reading needs the same care as writing: 1. **Default** missing keys at read time: `searchParams.get("page") ?? "1"`. 2. **Parse and validate**: `Number(raw)`, then `Number.isInteger(page) && page >= 1`, falling back to 1 otherwise. 3. **Allow-list** enumerations: accept `sort` only if it is one of the supported orders. 4. **Clamp** against real data once it is known: page 40 of a 3-page result should show the last page or an empty state, not an error. Keep this logic in one hook per screen, such as `useProductListQuery()`, that returns typed, validated values, so components never touch raw strings. ## Summary - `setSearchParams` replaces the whole query; objects do not merge. - The callback receives a copy of the current params: mutate it and return it. - Each call navigates and pushes unless `{ replace: true }`. - Never mutate `searchParams` directly; the URL is the source of truth.
- In React Router v7, is searchParams safe to use in a useEffect dependency list?Yes. The hook memoises it on `location.search`, so the reference only changes when the query string changes. An effect that depends on `searchParams` therefore re-runs on real query changes, not on every render. Read the specific values inside the effect with `get`.
- In React Router v7, how do you store several selected brands in the query?Repeat the key: append each value (`prev.append("brand", "nike")`), or pass an object with an array (`{ brand: ["nike", "reebok"] }`) when defining the whole query. Read them with `searchParams.getAll("brand")`, because `get` returns only the first value.
saying these in an interview costs you the question
- setSearchParams merges an object into the existing query like a partial update
- Mutating searchParams with set() updates the URL on the next render
- setSearchParams replaces the history entry by default
- The callback's argument is the same object components are reading
- searchParams.get returns undefined for a missing key