diff --git a/contributors.yml b/contributors.yml
index 0966b71f78..d2041f3fcb 100644
--- a/contributors.yml
+++ b/contributors.yml
@@ -473,3 +473,4 @@
- zeromask1337
- zheng-chuang
- zxTomw
+- DukeDeSouth
diff --git a/packages/react-router/__tests__/dom/search-params-test.tsx b/packages/react-router/__tests__/dom/search-params-test.tsx
index 33ed179985..e966a4b0dc 100644
--- a/packages/react-router/__tests__/dom/search-params-test.tsx
+++ b/packages/react-router/__tests__/dom/search-params-test.tsx
@@ -192,7 +192,147 @@ describe("useSearchParams", () => {
);
});
- it("does not reflect functional update mutation when navigation is blocked", () => {
+ // @see https://github.com/remix-run/react-router/issues/9991
+ it("returns a stable setSearchParams reference across renders", () => {
+ let setSearchParamsRefs: Function[] = [];
+
+ function SearchPage() {
+ let [searchParams, setSearchParams] = useSearchParams();
+ setSearchParamsRefs.push(setSearchParams);
+
+ return (
+
+
The current query is "{searchParams.get("q") || ""}".
+
+
+ );
+ }
+
+ act(() => {
+ ReactDOM.createRoot(node).render(
+
+
+ } />
+
+ ,
+ );
+ });
+
+ expect(node.innerHTML).toMatch(/The current query is "initial"/);
+ expect(setSearchParamsRefs.length).toBe(1);
+
+ act(() => {
+ node
+ .querySelector("button")!
+ .dispatchEvent(new Event("click", { bubbles: true }));
+ });
+
+ expect(node.innerHTML).toMatch(/The current query is "updated"/);
+ expect(setSearchParamsRefs.length).toBe(2);
+ expect(setSearchParamsRefs[0]).toBe(setSearchParamsRefs[1]);
+ });
+
+ // @see https://github.com/remix-run/react-router/issues/9991
+ it("does not re-trigger useEffect when search params change", () => {
+ let effectCount = 0;
+
+ function SearchPage() {
+ let [searchParams, setSearchParams] = useSearchParams();
+
+ React.useEffect(() => {
+ effectCount++;
+ }, [setSearchParams]);
+
+ return (
+
+
The current query is "{searchParams.get("q") || ""}".
+
+
+ );
+ }
+
+ act(() => {
+ ReactDOM.createRoot(node).render(
+
+
+ } />
+
+ ,
+ );
+ });
+
+ expect(effectCount).toBe(1);
+
+ act(() => {
+ node
+ .querySelector("button")!
+ .dispatchEvent(new Event("click", { bubbles: true }));
+ });
+
+ expect(effectCount).toBe(1);
+ });
+
+ // @see https://github.com/remix-run/react-router/issues/9991
+ it("provides current search params to functional updates after stabilization", () => {
+ function SearchPage() {
+ let [searchParams, setSearchParams] = useSearchParams();
+
+ return (
+
+
a={searchParams.get("a") || ""} b={searchParams.get("b") || ""}
+
+
+
+ );
+ }
+
+ act(() => {
+ ReactDOM.createRoot(node).render(
+
+
+ } />
+
+ ,
+ );
+ });
+
+ act(() => {
+ node
+ .querySelector("#set-a")!
+ .dispatchEvent(new Event("click", { bubbles: true }));
+ });
+
+ expect(node.innerHTML).toMatch(/a=1/);
+
+ act(() => {
+ node
+ .querySelector("#set-b")!
+ .dispatchEvent(new Event("click", { bubbles: true }));
+ });
+
+ expect(node.innerHTML).toMatch(/b=2/);
+ });
+
+ it("does not reflect functional update mutation when navigation is blocked", () => {
let router = createBrowserRouter([
{
path: "/",
diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx
index 39bc64675f..4d3a9db308 100644
--- a/packages/react-router/lib/dom/lib.tsx
+++ b/packages/react-router/lib/dom/lib.tsx
@@ -2310,17 +2310,29 @@ export function useSearchParams(
);
let navigate = useNavigate();
+
+ // Keep a ref to the latest searchParams so the callback can read
+ // the current value without listing it as a dependency. This makes
+ // setSearchParams reference-stable like React's setState.
+ // The ref is updated in an effect (not during render) to stay safe
+ // under React's concurrent rendering mode.
+ // @see https://github.com/remix-run/react-router/issues/9991
+ let searchParamsRef = React.useRef(searchParams);
+ React.useEffect(() => {
+ searchParamsRef.current = searchParams;
+ }, [searchParams]);
+
let setSearchParams = React.useCallback(
(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];