diff --git a/packages/docs/src/pages/ApiHooksPage.tsx b/packages/docs/src/pages/ApiHooksPage.tsx
index 6031d35..0fa14f9 100644
--- a/packages/docs/src/pages/ApiHooksPage.tsx
+++ b/packages/docs/src/pages/ApiHooksPage.tsx
@@ -46,6 +46,15 @@ function SearchPage() {
setSearchParams({ q: newQuery });
};
}`}
+
The setter accepts an optional second argument with navigation options:
+ {`// Push a new history entry instead of replacing the current one
+setSearchParams({ q: newQuery }, { replace: false });`}
+
+ By default (replace: true), the setter replaces the current history entry and
+ preserves its navigation state, including per-route state set via setState.
+ With replace: false, a new entry is pushed instead, so the change can be
+ undone with the browser's Back button.
+
diff --git a/packages/docs/src/pages/ApiReferenceIndexPage.tsx b/packages/docs/src/pages/ApiReferenceIndexPage.tsx
index af0bd85..6974c83 100644
--- a/packages/docs/src/pages/ApiReferenceIndexPage.tsx
+++ b/packages/docs/src/pages/ApiReferenceIndexPage.tsx
@@ -97,7 +97,7 @@ export function ApiReferenceIndexPage() {
RouteDefinition, ActionArgs, LoaderArgs,{" "}
- Location, NavigateOptions
+ Location, NavigateOptions, SetSearchParamsOptions
diff --git a/packages/docs/src/pages/ApiTypesPage.tsx b/packages/docs/src/pages/ApiTypesPage.tsx
index 759917f..9d7c54b 100644
--- a/packages/docs/src/pages/ApiTypesPage.tsx
+++ b/packages/docs/src/pages/ApiTypesPage.tsx
@@ -352,6 +352,20 @@ routeState<{ tab: string }>()({
navigation that triggered it.
+
+
+
+ SetSearchParamsOptions
+
+ {`interface SetSearchParamsOptions {
+ replace?: boolean; // default: true
+}`}
+
+ Options for the setter returned by useSearchParams. A replace navigation
+ preserves the current entry's state (including per-route state set via{" "}
+ setState), while a push navigation creates the new entry without state.
+
+
);
}
diff --git a/packages/router/src/__tests__/hooks.test.tsx b/packages/router/src/__tests__/hooks.test.tsx
index e18bfc8..1ac2f83 100644
--- a/packages/router/src/__tests__/hooks.test.tsx
+++ b/packages/router/src/__tests__/hooks.test.tsx
@@ -145,6 +145,58 @@ describe("hooks", () => {
});
});
+ it("preserves current entry state when updating search params", async () => {
+ mockNavigation = setupNavigationMock("http://localhost/page?foo=bar");
+
+ function TestComponent({ setStateSync }: { setStateSync?: (state: unknown) => void }) {
+ const [, setSearchParams] = useSearchParams();
+ return (
+
+ setStateSync?.({ scroll: 42 })}>SaveState
+ setSearchParams({ q: "new" })}>Update
+
+ );
+ }
+
+ const routes: RouteDefinition[] = [{ path: "/page", component: TestComponent }];
+
+ render( );
+
+ // Save route state on the current entry, then update search params
+ await act(async () => {
+ screen.getByText("SaveState").click();
+ });
+ await act(async () => {
+ screen.getByText("Update").click();
+ });
+
+ expect(mockNavigation.navigate).toHaveBeenLastCalledWith("/page?q=new", {
+ history: "replace",
+ state: { __routeStates: [{ scroll: 42 }] },
+ });
+ });
+
+ it("pushes a new entry when replace: false", () => {
+ mockNavigation = setupNavigationMock("http://localhost/page?foo=bar");
+
+ function TestComponent() {
+ const [, setSearchParams] = useSearchParams();
+ return (
+ setSearchParams({ q: "new" }, { replace: false })}>Update
+ );
+ }
+
+ const routes: RouteDefinition[] = [{ path: "/page", component: TestComponent }];
+
+ render( );
+ screen.getByRole("button").click();
+
+ expect(mockNavigation.navigate).toHaveBeenCalledWith("/page?q=new", {
+ history: "push",
+ state: undefined,
+ });
+ });
+
it("throws when used outside Router", () => {
function TestComponent() {
useSearchParams();
diff --git a/packages/router/src/hooks/useSearchParams.ts b/packages/router/src/hooks/useSearchParams.ts
index 494e270..d13419b 100644
--- a/packages/router/src/hooks/useSearchParams.ts
+++ b/packages/router/src/hooks/useSearchParams.ts
@@ -1,11 +1,23 @@
import { useCallback, useContext } from "react";
import { RouterContext } from "../context/RouterContext.js";
+/**
+ * Options for the setter returned by `useSearchParams`.
+ */
+export type SetSearchParamsOptions = {
+ /**
+ * Replace the current history entry instead of pushing a new one.
+ * @default true
+ */
+ replace?: boolean;
+};
+
type SetSearchParams = (
params:
| URLSearchParams
| Record
| ((prev: URLSearchParams) => URLSearchParams | Record),
+ options?: SetSearchParamsOptions,
) => void;
/**
@@ -23,11 +35,11 @@ export function useSearchParams(): [URLSearchParams, SetSearchParams] {
}
const currentUrl = context.url;
- const { navigateAsync } = context;
+ const { navigateAsync, locationState } = context;
const searchParams = currentUrl.searchParams;
const setSearchParams = useCallback(
- (params) => {
+ (params, options) => {
const url = new URL(currentUrl);
let newParams: URLSearchParams;
@@ -41,11 +53,17 @@ export function useSearchParams(): [URLSearchParams, SetSearchParams] {
}
url.search = newParams.toString();
+
+ const replace = options?.replace ?? true;
+ // Replacing the current entry keeps its state (including per-route
+ // state set via setState); pushing creates a fresh entry without
+ // state, like any other push navigation.
void navigateAsync(url.pathname + url.search + url.hash, {
- replace: true,
+ replace,
+ state: replace ? locationState : undefined,
});
},
- [currentUrl, navigateAsync],
+ [currentUrl, navigateAsync, locationState],
);
return [searchParams, setSearchParams];
diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts
index 89fc13a..d7962b5 100644
--- a/packages/router/src/index.ts
+++ b/packages/router/src/index.ts
@@ -8,7 +8,7 @@ export { Outlet } from "./Outlet.js";
// Hooks
export { useLocation } from "./hooks/useLocation.js";
-export { useSearchParams } from "./hooks/useSearchParams.js";
+export { useSearchParams, type SetSearchParamsOptions } from "./hooks/useSearchParams.js";
export { useBlocker, type UseBlockerOptions } from "./hooks/useBlocker.js";
export { useRouteParams } from "./hooks/useRouteParams.js";
export { useRouteState } from "./hooks/useRouteState.js";