diff --git a/packages/wouter-preact/src/preact-deps.js b/packages/wouter-preact/src/preact-deps.js
index 7315083b..3753c661 100644
--- a/packages/wouter-preact/src/preact-deps.js
+++ b/packages/wouter-preact/src/preact-deps.js
@@ -7,6 +7,7 @@ export {
Fragment,
} from "preact";
export {
+ useMemo,
useRef,
useLayoutEffect as useIsomorphicLayoutEffect,
useLayoutEffect as useInsertionEffect,
diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js
index 795abed2..aa6abec0 100644
--- a/packages/wouter/src/index.js
+++ b/packages/wouter/src/index.js
@@ -16,6 +16,7 @@ import {
forwardRef,
useIsomorphicLayoutEffect,
useEvent,
+ useMemo,
} from "./react-deps.js";
import { absolutePath, relativePath, sanitizeSearch } from "./paths.js";
@@ -202,6 +203,25 @@ const useCachedParams = (value) => {
return (prev.current = curr);
};
+export function useSearchParams() {
+ const [location, navigate] = useLocation();
+
+ const search = useSearch();
+ const searchParams = useMemo(() => new URLSearchParams(search), [search]);
+
+ // cached value before next render, so you can call setSearchParams multiple times
+ let tempSearchParams = searchParams;
+
+ const setSearchParams = useEvent((nextInit, options) => {
+ tempSearchParams = new URLSearchParams(
+ typeof nextInit === 'function' ? nextInit(tempSearchParams) : nextInit,
+ );
+ navigate(location + '?' + tempSearchParams, options);
+ })
+
+ return [searchParams, setSearchParams];
+}
+
export const Route = ({ path, nest, match, ...renderProps }) => {
const router = useRouter();
const [location] = useLocationFromRouter(router);
diff --git a/packages/wouter/src/react-deps.js b/packages/wouter/src/react-deps.js
index 61b560b6..573fbd1d 100644
--- a/packages/wouter/src/react-deps.js
+++ b/packages/wouter/src/react-deps.js
@@ -5,6 +5,7 @@ import * as React from "react";
const useBuiltinInsertionEffect = React["useInsertion" + "Effect"];
export {
+ useMemo,
useRef,
useState,
useContext,
diff --git a/packages/wouter/test/use-search-params.test.tsx b/packages/wouter/test/use-search-params.test.tsx
new file mode 100644
index 00000000..3795aa8c
--- /dev/null
+++ b/packages/wouter/test/use-search-params.test.tsx
@@ -0,0 +1,60 @@
+import { renderHook, act } from "@testing-library/react";
+import { useSearchParams, Router } from "wouter";
+import { navigate } from "wouter/use-browser-location";
+import { it, expect, beforeEach } from "vitest";
+
+beforeEach(() => history.replaceState(null, "", "/"));
+
+it("can return browser search params", () => {
+ history.replaceState(null, "", "/users?active=true");
+ const { result } = renderHook(() => useSearchParams());
+
+ expect(result.current[0].get('active')).toBe("true");
+});
+
+it("can change browser search params", () => {
+ history.replaceState(null, "", "/users?active=true");
+ const { result } = renderHook(() => useSearchParams());
+
+ expect(result.current[0].get('active')).toBe("true");
+
+ act(() => result.current[1](prev => {
+ prev.set('active', 'false');
+ return prev;
+ }));
+
+ expect(result.current[0].get('active')).toBe("false");
+});
+
+it("can be customized in the Router", () => {
+ const customSearchHook = ({ customOption = "unused" }) => "none";
+
+ const { result } = renderHook(() => useSearchParams(), {
+ wrapper: (props) => {
+ return {props.children};
+ },
+ });
+
+ expect(Array.from(result.current[0].keys())).toEqual(["none"]);
+});
+
+it("unescapes search string", () => {
+ const { result: searchResult } = renderHook(() => useSearchParams());
+
+ expect(Array.from(searchResult.current[0].keys()).length).toBe(0);
+
+ act(() => navigate("/?nonce=not Found&country=საქართველო"));
+ expect(searchResult.current[0].get('nonce')).toBe("not Found");
+ expect(searchResult.current[0].get('country')).toBe("საქართველო");
+
+ // question marks
+ act(() => navigate("/?вопрос=как дела?"));
+ expect(searchResult.current[0].get('вопрос')).toBe("как дела?");
+});
+
+it("is safe against parameter injection", () => {
+ history.replaceState(null, "", "/?search=foo%26parameter_injection%3Dbar");
+ const { result } = renderHook(() => useSearchParams());
+
+ expect(result.current[0].get('search')).toBe("foo¶meter_injection=bar");
+});
diff --git a/packages/wouter/types/index.d.ts b/packages/wouter/types/index.d.ts
index e1d17048..0f8f481c 100644
--- a/packages/wouter/types/index.d.ts
+++ b/packages/wouter/types/index.d.ts
@@ -185,6 +185,14 @@ export function useSearch<
H extends BaseSearchHook = BrowserSearchHook
>(): ReturnType;
+export type URLSearchParamsInit = ConstructorParameters[0];
+export type SetSearchParams = (
+ nextInit: URLSearchParamsInit | ((prev: URLSearchParams) => URLSearchParamsInit),
+ options?: { replace?: boolean; state?: any },
+) => void;
+
+export function useSearchParams(): [URLSearchParams, SetSearchParams];
+
export function useParams(): T extends string
? StringRouteParams
: T extends undefined