skip to content

In a React Router v7 product list, a category handler calls setSearchParams twice, once for category and once to reset page, and the category change is lost; why?

level: seniorimportance: should knowfreq 38%

answer

  1. not React's setState queue
  2. both callbacks see the same snapshot
  3. each call is its own navigation
  4. one call, all mutations

basics

~20 s

The setSearchParams callback does not queue like React's setState: both calls start from the params of the current render, and each starts a navigation. The second call's URL, which lacks the new category, wins. Make every change in one call.

solid answer

~40 s

`setSearchParams(fn)` looks like React's functional `setState`, but it does not queue. Each call runs `fn` on a copy of the `searchParams` from the current render and immediately navigates to the result. In the handler, the first call navigates to `?category=boots&sort=price&page=4`, and the second builds from the same old snapshot, `?category=shoes&sort=price&page=4`, deletes `page` and navigates to `?category=shoes&sort=price`. The second navigation wins, so category reverts; depending on the router mode, the first may also leave an extra history entry or be interrupted. The React Router docs warn about this directly. The fix is one call that applies every mutation, often through a small helper, plus `{ replace: true }` where rapid updates such as typing should not each become a Back-button stop.

code

tsx · 15 lines
tsx
import { useSearchParams } from "react-router";

export function useUpdateSearch() {
  const [, setSearchParams] = useSearchParams();
  return (mutate: (p: URLSearchParams) => void, opts?: { replace?: boolean }) =>
    setSearchParams((prev) => {
      mutate(prev);
      return prev;
    }, opts);
}

// usage inside a filter component:
// const update = useUpdateSearch();
// update((p) => { p.set("category", "boots"); p.delete("page"); });
// update((p) => p.set("q", text), { replace: true });

go deeper

for a junior

Recall that each setSearchParams call is a separate navigation, so make all your query changes in one call.

for a middle

Explain that the callback copies the current render's params instead of chaining, why the last call wins, and how replace differs from push.

for a senior

Diagnose lost filter updates and stray history entries, introduce a single-update helper, and choose replace or push per interaction type.

for a principal

Provide one sanctioned API for query updates so every list screen resets dependent keys consistently and emits one navigation per user action.

## The bug A product list at `/products?category=shoes&sort=price&page=4` has a category dropdown. Its handler was written in two steps, which reads naturally: ```tsx setSearchParams((prev) => { prev.set("category", "boots"); return prev; }); setSearchParams((prev) => { prev.delete("page"); return prev; }); ``` After selecting "boots", the URL shows `?category=shoes&sort=price`. The page reset worked; the category change vanished. ## Why: no queue, same snapshot The callback form resembles React's functional `setState`, where each updater receives the result of the previous one. React Router's `setSearchParams` does not do that. Its implementation, in order: 1. takes `searchParams` from the **current render** (the value memoised from `location.search`); 2. calls your function with `new URLSearchParams(searchParams)`, a copy of that snapshot; 3. immediately calls `navigate("?" + result, options)`. Both calls in the handler run before any re-render, so both see the **same snapshot** with `category=shoes&page=4`: | Call | Input snapshot | Navigates to | |---|---|---| | first | `category=shoes&sort=price&page=4` | `?category=boots&sort=price&page=4` | | second | `category=shoes&sort=price&page=4` | `?category=shoes&sort=price` | The second navigation is the last one, so it determines the URL. The React Router docs state it plainly: the callback version does not support the queueing logic of React's `setState`, and multiple calls in the same tick will not build on the prior value. ## Side effects of the extra navigation Beyond the lost update, two navigations for one user action cause noise that depends on the router mode and timing: - With `<BrowserRouter>`, both calls push, so the history gains a stray entry: Back from the final state lands on the intermediate `?category=boots&...&page=4` view. - With a data router, a new navigation aborts one still in progress, so the first may be cancelled, including any loaders it had started. Either way, one user action should produce **one** navigation. ## The fix: one call, every mutation ```tsx setSearchParams((prev) => { prev.set("category", "boots"); prev.delete("page"); return prev; }); ``` To make that the default, give list screens a helper that accepts a mutator and always performs a single update: ```tsx function useUpdateSearch() { const [, setSearchParams] = useSearchParams(); return (mutate: (p: URLSearchParams) => void, opts?: { replace?: boolean }) => setSearchParams((prev) => { mutate(prev); return prev; }, opts); } ``` Filter components then call `update((p) => { p.set("category", c); p.delete("page"); })`, and the pagination reset can never be split into a second navigation. ## Related: high-frequency updates A search box that writes `?q=` on every keystroke has the opposite problem: many correct navigations, each pushing a history entry, so Back steps through every letter. Use `setSearchParams(fn, { replace: true })` for keystroke-level updates, and consider debouncing the write so the list is not re-queried on every character. Deliberate choices, such as picking a category or a page, should keep the default push so Back undoes them. ## How to spot it in a code review or a bug report - **Symptom**: one of two changes made by a single control is lost, or Back shows an intermediate state nobody chose. - **Search**: look for handlers that call `setSearchParams` more than once, including indirect calls through two separate helper functions that each call it. - **Watch for effects**: an effect that "fixes up" the query (resetting `page` when `category` changes) is a close cousin: two navigations for one action, and one render with an inconsistent query in between; move the reset into the handler that changed `category`. - **Confirm**: log the URL after the handler; two navigations appear where one was intended. ## Checklist - One user action, one `setSearchParams` call. - Reset dependent keys (`page`) inside the same callback as the change that invalidates them. - `replace: true` for typing and sliders; default push for discrete choices. - If several parts of the UI must contribute to one update, collect their changes first and call the setter once. ## Summary - `setSearchParams` callbacks read the render's snapshot; they do not chain. - Each call navigates, so the last call wins and extra calls leave noise. - Apply every mutation in one callback, ideally through a shared helper.

  • In React Router v7, when you need updates to build on each other, what do the docs suggest instead of repeated setSearchParams calls?
    They say to use React's own `setState` yourself if you need queued updates, for example holding pending changes in state and writing the URL once. In practice most code avoids the need entirely by applying all mutations inside a single callback, which is both simpler and produces a single navigation.
  • In React Router v7, does calling setSearchParams inside a useEffect cause problems?
    It can. Each call navigates, and if the effect depends on `searchParams` and writes a value that differs from the current query, it can trigger itself again. Guard the write so it only happens when the target query differs, and prefer deriving defaults at read time over writing them into the URL from an effect.

Two setSearchParams calls in one handler are like two clerks each photocopying the same original form, each correcting one field, and both filing their copy: the file ends up holding whichever copy was filed last, with only that clerk's correction.

saying these in an interview costs you the question

  • setSearchParams callbacks queue and chain like React's functional setState
  • Two setSearchParams calls in one handler are batched into one navigation
  • The first call wins because later navigations are ignored while one is pending
  • Use replace: true to make consecutive setSearchParams calls merge
  • Every search-param change should use replace to keep history clean