Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/stable-set-search-params.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router": patch
---

fix: make setSearchParams return a stable reference
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,7 @@
- valerii15298
- ValiantCat
- vdusart
- veeceey
- vesan
- vezaynk
- VictorElHajj
Expand Down
167 changes: 167 additions & 0 deletions packages/react-router/__tests__/dom/search-params-test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,170 @@ describe("useSearchParams", () => {
`);
});
});

describe("useSearchParams setSearchParams stability", () => {
let node: HTMLDivElement;
beforeEach(() => {
node = document.createElement("div");
document.body.appendChild(node);
});

afterEach(() => {
document.body.removeChild(node);
node = null!;
});

it("returns a stable setSearchParams reference across re-renders caused by search param changes", () => {
let setSearchParamsRefs: Array<Function> = [];

function SearchPage() {
let [searchParams, setSearchParams] = useSearchParams();
setSearchParamsRefs.push(setSearchParams);

return (
<div>
<p>The current query is "{searchParams.get("q") || ""}".</p>
<button onClick={() => setSearchParams({ q: "new-value" })}>
Update
</button>
</div>
);
}

act(() => {
ReactDOM.createRoot(node).render(
<MemoryRouter initialEntries={["/search?q=initial"]}>
<Routes>
<Route path="search" element={<SearchPage />} />
</Routes>
</MemoryRouter>,
);
});

expect(node.innerHTML).toMatch(/The current query is "initial"/);
expect(setSearchParamsRefs.length).toBe(1);

// Click the button to update search params
act(() => {
node
.querySelector("button")!
.dispatchEvent(new Event("click", { bubbles: true }));
});

expect(node.innerHTML).toMatch(/The current query is "new-value"/);
expect(setSearchParamsRefs.length).toBe(2);

// The setSearchParams reference should be the same across renders
expect(setSearchParamsRefs[0]).toBe(setSearchParamsRefs[1]);
});

it("does not cause unnecessary re-renders in child components that depend only on setSearchParams", () => {
let childRenderCount = 0;

let Child = React.memo(function Child({
setSearchParams,
}: {
setSearchParams: Function;
}) {
childRenderCount++;
return (
<button
onClick={() =>
(setSearchParams as any)((prev: URLSearchParams) => {
prev.set("count", String(Number(prev.get("count") || "0") + 1));
return prev;
})
}
>
Increment
</button>
);
});

function Parent() {
let [searchParams, setSearchParams] = useSearchParams();
return (
<div>
<p>Count: {searchParams.get("count") || "0"}</p>
<Child setSearchParams={setSearchParams} />
</div>
);
}

act(() => {
ReactDOM.createRoot(node).render(
<MemoryRouter initialEntries={["/search?count=0"]}>
<Routes>
<Route path="search" element={<Parent />} />
</Routes>
</MemoryRouter>,
);
});

expect(node.innerHTML).toMatch(/Count: 0/);
expect(childRenderCount).toBe(1);

// Click to increment
act(() => {
node
.querySelector("button")!
.dispatchEvent(new Event("click", { bubbles: true }));
});

expect(node.innerHTML).toMatch(/Count: 1/);
// Child should not have re-rendered since setSearchParams is stable
expect(childRenderCount).toBe(1);
});

it("provides the latest search params value in functional updates even with a stable reference", () => {
function SearchPage() {
let [searchParams, setSearchParams] = useSearchParams();

return (
<div>
<p>Count: {searchParams.get("count") || "0"}</p>
<button
id="increment"
onClick={() =>
setSearchParams((prev) => {
let current = Number(prev.get("count") || "0");
return { count: String(current + 1) };
})
}
>
Increment
</button>
</div>
);
}

act(() => {
ReactDOM.createRoot(node).render(
<MemoryRouter initialEntries={["/search?count=0"]}>
<Routes>
<Route path="search" element={<SearchPage />} />
</Routes>
</MemoryRouter>,
);
});

expect(node.innerHTML).toMatch(/Count: 0/);

// Increment multiple times
act(() => {
node
.querySelector("#increment")!
.dispatchEvent(new Event("click", { bubbles: true }));
});

expect(node.innerHTML).toMatch(/Count: 1/);

act(() => {
node
.querySelector("#increment")!
.dispatchEvent(new Event("click", { bubbles: true }));
});

expect(node.innerHTML).toMatch(/Count: 2/);
});
});
7 changes: 5 additions & 2 deletions packages/react-router/lib/dom/lib.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2309,18 +2309,21 @@ export function useSearchParams(
[location.search],
);

let searchParamsRef = React.useRef(searchParams);
searchParamsRef.current = searchParams;

let navigate = useNavigate();
let setSearchParams = React.useCallback<SetURLSearchParams>(
(nextInit, navigateOptions) => {
const newSearchParams = createSearchParams(
typeof nextInit === "function"
? nextInit(new URLSearchParams(searchParams))
? nextInit(new URLSearchParams(searchParamsRef.current))
: nextInit,
);
hasSetSearchParamsRef.current = true;
navigate("?" + newSearchParams, navigateOptions);
},
[navigate, searchParams],
[navigate],
);

return [searchParams, setSearchParams];
Expand Down