diff --git a/.husky/pre-commit b/.husky/pre-commit
index 0ad376d..efbbad2 100644
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1 +1,2 @@
-npm run check
+npx lint-staged
+npm run prebuild
diff --git a/apps/playground/src/App.tsx b/apps/playground/src/App.tsx
index 8ff6d62..0712974 100644
--- a/apps/playground/src/App.tsx
+++ b/apps/playground/src/App.tsx
@@ -1,4 +1,4 @@
-// import {useUrlParams, useBatchUrlParams} from 'react-url-query-params'
+import { useBatchUrlParams, useUrlParams } from 'react-url-query-params';
import { type ExportController, ExportControllerSingleton } from 'export-csv-core';
import { useEffect, useRef, useState } from 'react';
import viteLogo from '/vite.svg';
@@ -50,6 +50,49 @@ function useMessageExportCSV(cb: (payload: Payload) => void) {
}, [cb]);
}
+function UrlParamsDemo() {
+ const { view, setView, toggleView, clearView, isViewGrid, isViewTable } = useUrlParams({
+ keyName: 'view',
+ options: ['grid', 'table'] as const,
+ });
+
+ const { set, clearParams, isFilterActive, isFilterInactive, isSortAsc, isSortDesc } = useBatchUrlParams({
+ filter: ['active', 'inactive'] as const,
+ sort: ['asc', 'desc'] as const,
+ });
+
+ return (
+
+
react-url-query-params demo
+
+
+ useUrlParams — single param
+ Current URL param ?view: {view ?? 'null'}
+ isViewGrid: {String(isViewGrid)} | isViewTable: {String(isViewTable)}
+
+ setView('grid')}>setView('grid')
+ setView('table')}>setView('table')
+ toggleView()}>toggleView()
+ clearView()}>clearView()
+ setView('grid', { replace: true })}>setView('grid', replace)
+
+
+
+
+ useBatchUrlParams — multiple params
+ isFilterActive: {String(isFilterActive)} | isFilterInactive: {String(isFilterInactive)}
+ isSortAsc: {String(isSortAsc)} | isSortDesc: {String(isSortDesc)}
+
+ set({ filter: 'active', sort: 'asc' })}>set active + asc
+ set({ filter: 'inactive', sort: 'desc' })}>set inactive + desc
+ set({ filter: 'active' })}>set filter only
+ clearParams()}>clearParams()
+
+
+
+ );
+}
+
function App() {
const [count, setCount] = useState(0);
@@ -61,6 +104,7 @@ function App() {
return (
<>
+
=18"
}
},
+ "node_modules/@emnapi/runtime": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+ "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -1353,46 +1364,537 @@
"node": ">=18.18.0"
}
},
- "node_modules/@humanfs/node": {
- "version": "0.16.7",
- "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
- "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@img/colour": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
+ "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@img/sharp-darwin-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+ "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-darwin-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+ "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-darwin-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+ "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-darwin-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+ "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+ "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+ "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-ppc64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+ "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-riscv64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+ "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-s390x": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+ "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linux-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+ "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+ "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+ "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+ "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+ "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-ppc64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+ "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-ppc64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-riscv64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+ "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-riscv64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-s390x": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+ "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-s390x": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linux-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+ "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linux-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+ "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-linuxmusl-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+ "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+ }
+ },
+ "node_modules/@img/sharp-wasm32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+ "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+ "cpu": [
+ "wasm32"
+ ],
"dev": true,
- "license": "Apache-2.0",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+ "optional": true,
"dependencies": {
- "@humanfs/core": "^0.19.1",
- "@humanwhocodes/retry": "^0.4.0"
+ "@emnapi/runtime": "^1.7.0"
},
"engines": {
- "node": ">=18.18.0"
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
}
},
- "node_modules/@humanwhocodes/module-importer": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
- "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "node_modules/@img/sharp-win32-arm64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+ "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+ "cpu": [
+ "arm64"
+ ],
"dev": true,
- "license": "Apache-2.0",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
"engines": {
- "node": ">=12.22"
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
+ "url": "https://opencollective.com/libvips"
}
},
- "node_modules/@humanwhocodes/retry": {
- "version": "0.4.3",
- "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
- "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "node_modules/@img/sharp-win32-ia32": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+ "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+ "cpu": [
+ "ia32"
+ ],
"dev": true,
- "license": "Apache-2.0",
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
"engines": {
- "node": ">=18.18"
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
- "type": "github",
- "url": "https://github.com/sponsors/nzakas"
+ "url": "https://opencollective.com/libvips"
+ }
+ },
+ "node_modules/@img/sharp-win32-x64": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+ "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "Apache-2.0 AND LGPL-3.0-or-later",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
}
},
"node_modules/@jridgewell/gen-mapping": {
@@ -1445,6 +1947,149 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@next/env": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
+ "integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@next/swc-darwin-arm64": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
+ "integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-darwin-x64": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
+ "integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-gnu": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
+ "integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-arm64-musl": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
+ "integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-gnu": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
+ "integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-linux-x64-musl": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
+ "integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-arm64-msvc": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
+ "integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
+ "node_modules/@next/swc-win32-x64-msvc": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
+ "integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 10"
+ }
+ },
"node_modules/@remix-run/router": {
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
@@ -1777,6 +2422,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@swc/helpers": {
+ "version": "0.5.15",
+ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+ "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.8.0"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -2781,6 +3436,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/client-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2976,6 +3638,17 @@
"node": ">=6"
}
},
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@@ -4199,6 +4872,89 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/next": {
+ "version": "16.1.6",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
+ "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@next/env": "16.1.6",
+ "@swc/helpers": "0.5.15",
+ "baseline-browser-mapping": "^2.8.3",
+ "caniuse-lite": "^1.0.30001579",
+ "postcss": "8.4.31",
+ "styled-jsx": "5.1.6"
+ },
+ "bin": {
+ "next": "dist/bin/next"
+ },
+ "engines": {
+ "node": ">=20.9.0"
+ },
+ "optionalDependencies": {
+ "@next/swc-darwin-arm64": "16.1.6",
+ "@next/swc-darwin-x64": "16.1.6",
+ "@next/swc-linux-arm64-gnu": "16.1.6",
+ "@next/swc-linux-arm64-musl": "16.1.6",
+ "@next/swc-linux-x64-gnu": "16.1.6",
+ "@next/swc-linux-x64-musl": "16.1.6",
+ "@next/swc-win32-arm64-msvc": "16.1.6",
+ "@next/swc-win32-x64-msvc": "16.1.6",
+ "sharp": "^0.34.4"
+ },
+ "peerDependencies": {
+ "@opentelemetry/api": "^1.1.0",
+ "@playwright/test": "^1.51.1",
+ "babel-plugin-react-compiler": "*",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+ "sass": "^1.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@playwright/test": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/next/node_modules/postcss": {
+ "version": "8.4.31",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+ "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.6",
+ "picocolors": "^1.0.0",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -4758,6 +5514,66 @@
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
+ "node_modules/sharp": {
+ "version": "0.34.5",
+ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "@img/colour": "^1.0.0",
+ "detect-libc": "^2.1.2",
+ "semver": "^7.7.3"
+ },
+ "engines": {
+ "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/libvips"
+ },
+ "optionalDependencies": {
+ "@img/sharp-darwin-arm64": "0.34.5",
+ "@img/sharp-darwin-x64": "0.34.5",
+ "@img/sharp-libvips-darwin-arm64": "1.2.4",
+ "@img/sharp-libvips-darwin-x64": "1.2.4",
+ "@img/sharp-libvips-linux-arm": "1.2.4",
+ "@img/sharp-libvips-linux-arm64": "1.2.4",
+ "@img/sharp-libvips-linux-ppc64": "1.2.4",
+ "@img/sharp-libvips-linux-riscv64": "1.2.4",
+ "@img/sharp-libvips-linux-s390x": "1.2.4",
+ "@img/sharp-libvips-linux-x64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+ "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+ "@img/sharp-linux-arm": "0.34.5",
+ "@img/sharp-linux-arm64": "0.34.5",
+ "@img/sharp-linux-ppc64": "0.34.5",
+ "@img/sharp-linux-riscv64": "0.34.5",
+ "@img/sharp-linux-s390x": "0.34.5",
+ "@img/sharp-linux-x64": "0.34.5",
+ "@img/sharp-linuxmusl-arm64": "0.34.5",
+ "@img/sharp-linuxmusl-x64": "0.34.5",
+ "@img/sharp-wasm32": "0.34.5",
+ "@img/sharp-win32-arm64": "0.34.5",
+ "@img/sharp-win32-ia32": "0.34.5",
+ "@img/sharp-win32-x64": "0.34.5"
+ }
+ },
+ "node_modules/sharp/node_modules/semver": {
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+ "dev": true,
+ "license": "ISC",
+ "optional": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4947,6 +5763,30 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/styled-jsx": {
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+ "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "client-only": "0.0.1"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "@babel/core": {
+ "optional": true
+ },
+ "babel-plugin-macros": {
+ "optional": true
+ }
+ }
+ },
"node_modules/sucrase": {
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
@@ -5143,6 +5983,13 @@
"dev": true,
"license": "Apache-2.0"
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
"node_modules/tsup": {
"version": "8.5.1",
"resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz",
@@ -5726,6 +6573,7 @@
"@types/react-dom": "^18",
"@vitest/coverage-v8": "4.0.16",
"jsdom": "^27.4.0",
+ "next": "^16.1.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router-dom": "^6.30.2",
diff --git a/package.json b/package.json
index fbf7889..d108743 100644
--- a/package.json
+++ b/package.json
@@ -7,10 +7,7 @@
"typescript": "^5.9.3"
},
"lint-staged": {
- "*.{ts,tsx}": [
- "check:write",
- "prebuild"
- ]
+ "*.{ts,tsx}": "npx @biomejs/biome check --write"
},
"name": "utils-kit",
"private": true,
diff --git a/packages/react-url-query-params/README.md b/packages/react-url-query-params/README.md
index 051792e..d0f28de 100644
--- a/packages/react-url-query-params/README.md
+++ b/packages/react-url-query-params/README.md
@@ -1,28 +1,32 @@
-# useUrlParams
+# react-url-query-params
[](https://www.npmjs.com/package/react-url-query-params)
[](https://www.npmjs.com/package/react-url-query-params)
+[](https://nextjs.org/)
[](../../LICENSE)
[](https://github.com/PashaSchool/utils-kit/actions/workflows/CI.yml)
-
A lightweight React hook library for managing **URL query parameters** with full TypeScript support and auto-generated helper methods.
-Built for [`react-router-dom`](https://reactrouter.com/) (v6+) with **type-safe keys**, **option validation**, and **handy helper flags**.
+Works with **`react-router-dom`**, **Next.js App Router**, and **Next.js Pages Router** — same API, just swap the import path.
+
+## Router Support
-The library provides two hooks:
-- **`useUrlParams`** - Manage a single query parameter
-- **`useBulkUrlParams`** - Manage multiple query parameters at once
+| Import path | Router | When to use |
+|---|---|---|
+| `react-url-query-params` | `react-router-dom` v6+ | CRA, Vite, Remix |
+| `react-url-query-params/next` | Next.js App Router (`next/navigation`) | Next.js 13+ with `app/` directory |
+| `react-url-query-params/next-pages` | Next.js Pages Router (`next/router`) | Next.js with `pages/` directory |
---
## Features
+- **Next.js ready** — App Router and Pages Router both supported out of the box
- **Type-safe** query parameter keys and values
- **Auto-generated helpers**: `set`, `toggle`, `is`, `clear`
- **Toggle mode** for 2-option parameters
-- Works seamlessly with `react-router-dom`’s `useSearchParams`
-- Zero dependencies (except React & react-router-dom)
+- Zero dependencies (except React & your router)
---
@@ -35,7 +39,140 @@ or
```bash
yarn add react-url-query-params
```
-## Usage
+
+---
+
+## Next.js — App Router
+
+### Important: Suspense Boundary Required
+
+Components using hooks from `react-url-query-params/next` call `useSearchParams()` from `next/navigation` internally. Next.js requires these to be wrapped in a `` boundary:
+
+```tsx
+import { Suspense } from ‘react’;
+import MyComponent from ‘./MyComponent’;
+
+export default function Page() {
+ return (
+ Loading...
}>
+
+
+ );
+}
+```
+
+### Single Parameter: `useUrlParams`
+
+```tsx
+‘use client’;
+
+import { useUrlParams } from ‘react-url-query-params/next’;
+
+export default function MyComponent() {
+ const { view, setView, toggleView, clearView, isViewGrid, isViewTable } = useUrlParams({
+ keyName: ‘view’,
+ options: [‘grid’, ‘table’] as const,
+ });
+
+ return (
+
+
Current view: {view}
+
setView(‘grid’)}>Grid
+
setView(‘table’)}>Table
+
toggleView()}>Toggle
+
clearView()}>Clear
+ {/* Replace history entry instead of adding a new one */}
+
setView(‘grid’, { replace: true })}>Grid (No History)
+ {isViewGrid &&
Grid mode enabled
}
+ {isViewTable &&
Table mode enabled
}
+
+ );
+}
+```
+
+### Multiple Parameters: `useBatchUrlParams`
+
+```tsx
+‘use client’;
+
+import { useBatchUrlParams } from ‘react-url-query-params/next’;
+
+export default function FilterPanel() {
+ const { set, clearParams, isFilterActive, isSortDesc } = useBatchUrlParams({
+ filter: [‘active’, ‘inactive’] as const,
+ sort: [‘asc’, ‘desc’] as const,
+ });
+
+ return (
+
+ set({ filter: ‘active’, sort: ‘asc’ })}>Active + Asc
+ set({ sort: ‘desc’ }, { replace: true })}>Desc (No History)
+ clearParams()}>Clear all
+ {isFilterActive && Showing active }
+ {isSortDesc && Sorted descending }
+
+ );
+}
+```
+
+---
+
+## Next.js — Pages Router
+
+### Important: Params Are Null on First Render
+
+In the Pages Router, `router.isReady` is `false` on the first render during SSR/hydration. During this render all hook values return `null` and all boolean flags return `false`. The component re-renders automatically once the router is ready with real URL values.
+
+### Single Parameter: `useUrlParams`
+
+```tsx
+import { useUrlParams } from ‘react-url-query-params/next-pages’;
+
+export default function MyComponent() {
+ const { view, setView, toggleView, clearView, isViewGrid, isViewTable } = useUrlParams({
+ keyName: ‘view’,
+ options: [‘grid’, ‘table’] as const,
+ });
+
+ return (
+
+
Current view: {view ?? ‘loading...’}
+
setView(‘grid’)}>Grid
+
setView(‘table’)}>Table
+
toggleView()}>Toggle
+
clearView()}>Clear
+ {isViewGrid &&
Grid mode enabled
}
+ {isViewTable &&
Table mode enabled
}
+
+ );
+}
+```
+
+### Multiple Parameters: `useBatchUrlParams`
+
+```tsx
+import { useBatchUrlParams } from ‘react-url-query-params/next-pages’;
+
+export default function FilterPanel() {
+ const { set, clearParams, isFilterActive, isSortDesc } = useBatchUrlParams({
+ filter: [‘active’, ‘inactive’] as const,
+ sort: [‘asc’, ‘desc’] as const,
+ });
+
+ return (
+
+ set({ filter: ‘active’ })}>Active
+ set({ sort: ‘desc’ }, { replace: true })}>Desc (No History)
+ clearParams()}>Clear all
+ {isFilterActive && Showing active }
+
+ );
+}
+```
+
+---
+
+## Usage (react-router-dom)

diff --git a/packages/react-url-query-params/package.json b/packages/react-url-query-params/package.json
index 18af893..6ed2918 100644
--- a/packages/react-url-query-params/package.json
+++ b/packages/react-url-query-params/package.json
@@ -14,6 +14,7 @@
"@types/react-dom": "^18",
"@vitest/coverage-v8": "4.0.16",
"jsdom": "^27.4.0",
+ "next": "^16.1.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router-dom": "^6.30.2",
@@ -23,8 +24,19 @@
},
"exports": {
".": {
+ "types": "./dist/index.d.ts",
"import": "./dist/index.js",
- "reqnpm run -w packages/react-url-query-params builduire": "./dist/index.cjs"
+ "require": "./dist/index.cjs"
+ },
+ "./next": {
+ "types": "./dist/next/index.d.ts",
+ "import": "./dist/next/index.js",
+ "require": "./dist/next/index.cjs"
+ },
+ "./next-pages": {
+ "types": "./dist/next-pages/index.d.ts",
+ "import": "./dist/next-pages/index.js",
+ "require": "./dist/next-pages/index.cjs"
}
},
"files": [
@@ -40,17 +52,30 @@
"query",
"params",
"typescript",
- "react-router-dom"
+ "react-router-dom",
+ "next",
+ "nextjs",
+ "app-router",
+ "pages-router"
],
"license": "MIT",
"main": "dist/index.cjs",
"module": "dist/index.js",
"name": "react-url-query-params",
"peerDependencies": {
+ "next": ">=13.0.0",
"react": "^18 || ^19",
"react-dom": "^18 || ^19",
"react-router-dom": "^6.4 || ^7"
},
+ "peerDependenciesMeta": {
+ "next": {
+ "optional": true
+ },
+ "react-router-dom": {
+ "optional": true
+ }
+ },
"repository": {
"directory": "packages/react-url-query-params",
"type": "git",
@@ -66,5 +91,5 @@
"sideEffects": false,
"type": "module",
"types": "dist/index.d.ts",
- "version": "0.4.11"
+ "version": "0.5.0"
}
diff --git a/packages/react-url-query-params/src/next-pages/index.ts b/packages/react-url-query-params/src/next-pages/index.ts
new file mode 100644
index 0000000..472a689
--- /dev/null
+++ b/packages/react-url-query-params/src/next-pages/index.ts
@@ -0,0 +1,2 @@
+export { default as useBatchUrlParams } from "./useBulkUrlParams";
+export { default as useUrlParams } from "./useUrlParams";
diff --git a/packages/react-url-query-params/src/next-pages/useBulkUrlParams.test.tsx b/packages/react-url-query-params/src/next-pages/useBulkUrlParams.test.tsx
new file mode 100644
index 0000000..0f1d397
--- /dev/null
+++ b/packages/react-url-query-params/src/next-pages/useBulkUrlParams.test.tsx
@@ -0,0 +1,73 @@
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import useBulkUrlParams from "./useBulkUrlParams";
+
+let mockIsReady = true;
+let mockQuery: Record = {};
+const mockPathname = "/test";
+const mockPush = vi.fn();
+const mockReplace = vi.fn();
+
+vi.mock("next/router", () => ({
+ useRouter: () => ({
+ isReady: mockIsReady,
+ pathname: mockPathname,
+ push: mockPush,
+ query: mockQuery,
+ replace: mockReplace,
+ }),
+}));
+
+describe("useBulkUrlParams (Pages Router)", () => {
+ beforeEach(() => {
+ mockIsReady = true;
+ mockQuery = {};
+ mockPush.mockClear();
+ mockReplace.mockClear();
+ });
+
+ it("initiate hook with all needed params", () => {
+ const { result } = renderHook(() => useBulkUrlParams({ filter: ["active", "inactive"], sort: ["asc", "desc"] }));
+ expect(result.current.isFilterActive).toBe(false);
+ expect(result.current.isFilterInactive).toBe(false);
+ expect(result.current.isSortAsc).toBe(false);
+ expect(result.current.isSortDesc).toBe(false);
+ expect(result.current.set).toBeTypeOf("function");
+ });
+
+ it("all flags are false when router is not ready", () => {
+ mockIsReady = false;
+ mockQuery = { filter: "active" };
+ const { result } = renderHook(() => useBulkUrlParams({ filter: ["active", "inactive"], sort: ["asc", "desc"] }));
+ expect(result.current.isFilterActive).toBe(false);
+ expect(result.current.isSortDesc).toBe(false);
+ });
+
+ it("set calls router.push merging with existing query", () => {
+ mockQuery = { unrelated: "value" };
+ const { result } = renderHook(() => useBulkUrlParams({ filter: ["active", "inactive"], sort: ["asc", "desc"] }));
+ act(() => result.current.set({ filter: "active", sort: "asc" }));
+ expect(mockPush).toHaveBeenCalledWith(
+ { pathname: "/test", query: { filter: "active", sort: "asc", unrelated: "value" } },
+ undefined,
+ { shallow: true },
+ );
+ });
+
+ it("set calls router.replace when replace: true", () => {
+ const { result } = renderHook(() => useBulkUrlParams({ filter: ["active", "inactive"], sort: ["asc", "desc"] }));
+ act(() => result.current.set({ sort: "desc" }, { replace: true }));
+ expect(mockReplace).toHaveBeenCalledWith({ pathname: "/test", query: { sort: "desc" } }, undefined, {
+ shallow: true,
+ });
+ });
+
+ it("clearParams removes only declared keys, preserves others", () => {
+ mockQuery = { extra: "keep", filter: "inactive", sort: "desc" };
+ const { result } = renderHook(() => useBulkUrlParams({ filter: ["active", "inactive"], sort: ["asc", "desc"] }));
+ act(() => result.current.clearParams());
+ expect(mockPush).toHaveBeenCalledWith({ pathname: "/test", query: { extra: "keep" } }, undefined, {
+ shallow: true,
+ });
+ });
+});
diff --git a/packages/react-url-query-params/src/next-pages/useBulkUrlParams.ts b/packages/react-url-query-params/src/next-pages/useBulkUrlParams.ts
new file mode 100644
index 0000000..5649c1b
--- /dev/null
+++ b/packages/react-url-query-params/src/next-pages/useBulkUrlParams.ts
@@ -0,0 +1,79 @@
+import { useRouter } from "next/router";
+import { useCallback, useMemo } from "react";
+import type { Capitalize, ParamsConfig } from "../types.utils";
+import { upperFirst } from "../utils";
+
+type BatchUrlReturnType> = {
+ set: (values: Partial<{ [K in keyof T]: T[K][number] }>, config?: ParamsConfig) => void;
+ clearParams: (config?: ParamsConfig) => void;
+} & {
+ [K in {
+ [Key in keyof T]: {
+ [Val in T[Key][number]]: `is${Capitalize}${Capitalize}`;
+ }[T[Key][number]];
+ }[keyof T]]: boolean;
+};
+
+function useBulkUrlParams>(config: T): BatchUrlReturnType {
+ const router = useRouter();
+
+ type Config = { [K in keyof T]: T[K][number] };
+
+ const navigate = useCallback(
+ (query: Record, paramsConfig: ParamsConfig = { replace: false }) => {
+ const method = paramsConfig.replace ? router.replace : router.push;
+ method({ pathname: router.pathname, query }, undefined, { shallow: true });
+ },
+ [router],
+ );
+
+ const setterFunction = useCallback(
+ (values: Partial, paramsConfig: ParamsConfig = { replace: false }) => {
+ const newQuery: Record = { ...(router.query as Record) };
+ Object.entries(values).forEach(([key, value]) => {
+ newQuery[key] = value as string;
+ });
+ navigate(newQuery, paramsConfig);
+ },
+ [router.query, navigate],
+ );
+
+ const capitalizedOptions = useMemo(() => {
+ const result = {} as { [key: string]: boolean };
+
+ Object.entries(config).forEach(([key, options]) => {
+ const capitalizedKeyName = upperFirst(key);
+ // When router is not ready, router.query is {} — all flags default to false.
+ const currentValue = router.isReady ? router.query[key] : null;
+ const currentStr = typeof currentValue === "string" ? currentValue : null;
+
+ (options as string[]).forEach((option) => {
+ const capitalizedOption = upperFirst(option);
+ Object.assign(result, {
+ [`is${capitalizedKeyName}${capitalizedOption}`]: currentStr === option,
+ });
+ });
+ });
+
+ return result;
+ }, [router.isReady, router.query, config]);
+
+ const clearParams = useCallback(
+ (paramsConfig: ParamsConfig = { replace: false }) => {
+ const newQuery: Record = { ...(router.query as Record) };
+ Object.keys(config).forEach((key) => {
+ delete newQuery[key];
+ });
+ navigate(newQuery, paramsConfig);
+ },
+ [router.query, config, navigate],
+ );
+
+ return {
+ clearParams,
+ set: setterFunction,
+ ...capitalizedOptions,
+ } as BatchUrlReturnType;
+}
+
+export default useBulkUrlParams;
diff --git a/packages/react-url-query-params/src/next-pages/useUrlParams.test.tsx b/packages/react-url-query-params/src/next-pages/useUrlParams.test.tsx
new file mode 100644
index 0000000..53b242f
--- /dev/null
+++ b/packages/react-url-query-params/src/next-pages/useUrlParams.test.tsx
@@ -0,0 +1,107 @@
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import useUrlParams from "./useUrlParams";
+
+let mockIsReady = false;
+let mockQuery: Record = {};
+const mockPathname = "/test";
+const mockPush = vi.fn();
+const mockReplace = vi.fn();
+
+vi.mock("next/router", () => ({
+ useRouter: () => ({
+ isReady: mockIsReady,
+ pathname: mockPathname,
+ push: mockPush,
+ query: mockQuery,
+ replace: mockReplace,
+ }),
+}));
+
+describe("useUrlParams (Pages Router)", () => {
+ beforeEach(() => {
+ mockIsReady = false;
+ mockQuery = {};
+ mockPush.mockClear();
+ mockReplace.mockClear();
+ });
+
+ it("returns null for all values when router is not ready", () => {
+ mockIsReady = false;
+ mockQuery = { view: "grid" };
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ expect(result.current.view).toBeNull();
+ expect(result.current.isViewGrid).toBe(false);
+ expect(result.current.isViewTable).toBe(false);
+ });
+
+ it("initiate with all provided params following correct interface", () => {
+ mockIsReady = true;
+ mockQuery = { view: "grid" };
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ expect(result.current.view).toBe("grid");
+ expect(result.current.isViewGrid).toBe(true);
+ expect(result.current.isViewTable).toBe(false);
+ expect(result.current.setView).toBeTypeOf("function");
+ expect(result.current.toggleView).toBeTypeOf("function");
+ expect(result.current.clearView).toBeTypeOf("function");
+ });
+
+ it("returns null when URL value is not in options", () => {
+ mockIsReady = true;
+ mockQuery = { view: "list" };
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ expect(result.current.view).toBeNull();
+ });
+
+ it("set calls router.push with shallow navigation", () => {
+ mockIsReady = true;
+ mockQuery = { view: "grid" };
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ act(() => result.current.setView("table"));
+ expect(mockPush).toHaveBeenCalledWith({ pathname: "/test", query: { view: "table" } }, undefined, {
+ shallow: true,
+ });
+ });
+
+ it("set calls router.replace when replace: true", () => {
+ mockIsReady = true;
+ mockQuery = {};
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ act(() => result.current.setView("grid", { replace: true }));
+ expect(mockReplace).toHaveBeenCalledWith({ pathname: "/test", query: { view: "grid" } }, undefined, {
+ shallow: true,
+ });
+ });
+
+ it("clear removes the param from query", () => {
+ mockIsReady = true;
+ mockQuery = { other: "preserved", view: "table" };
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ act(() => result.current.clearView());
+ expect(mockPush).toHaveBeenCalledWith({ pathname: "/test", query: { other: "preserved" } }, undefined, {
+ shallow: true,
+ });
+ });
+
+ it("toggle alternates between two options", () => {
+ mockIsReady = true;
+ mockQuery = { view: "grid" };
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ act(() => result.current.toggleView());
+ expect(mockPush).toHaveBeenCalledWith({ pathname: "/test", query: { view: "table" } }, undefined, {
+ shallow: true,
+ });
+ });
+
+ it("toggle not possible for more than two options", () => {
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ mockIsReady = true;
+ mockQuery = { view: "grid" };
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table", "dashboard"] }));
+ act(() => result.current.toggleView());
+ expect(mockPush).not.toHaveBeenCalled();
+ expect(warnSpy).toHaveBeenCalled();
+ warnSpy.mockRestore();
+ });
+});
diff --git a/packages/react-url-query-params/src/next-pages/useUrlParams.ts b/packages/react-url-query-params/src/next-pages/useUrlParams.ts
new file mode 100644
index 0000000..ef6d720
--- /dev/null
+++ b/packages/react-url-query-params/src/next-pages/useUrlParams.ts
@@ -0,0 +1,99 @@
+import { useRouter } from "next/router";
+import { useCallback, useMemo } from "react";
+import type { Capitalize, ParamsConfig, QueryParamConfig } from "../types.utils";
+import { upperFirst } from "../utils";
+
+type QueryParamHookResult = {
+ [K in O as `is${Capitalize}${Capitalize}`]: boolean;
+} & {
+ [K in `set${Capitalize}`]: (value: O, config?: ParamsConfig) => void;
+} & {
+ [K in T]: O | null;
+} & {
+ [K in `toggle${Capitalize}`]: (config?: ParamsConfig) => void;
+} & {
+ [K in `clear${Capitalize}`]: (config?: ParamsConfig) => void;
+};
+
+function useUrlParams(config: QueryParamConfig): QueryParamHookResult {
+ const router = useRouter();
+
+ // router.isReady is false on the first render during SSR/hydration.
+ // When not ready, router.query is {}, so we fall back to null.
+ const rawValue = router.isReady ? ((router.query[config.keyName] as string | undefined) ?? null) : null;
+
+ // Validate that the raw value is one of the declared options (guards against manually crafted URLs).
+ const currentValue: O | null = rawValue !== null && config.options.includes(rawValue as O) ? (rawValue as O) : null;
+
+ const navigate = useCallback(
+ (query: Record, paramsConfig: ParamsConfig = { replace: false }) => {
+ const method = paramsConfig.replace ? router.replace : router.push;
+ method({ pathname: router.pathname, query }, undefined, { shallow: true });
+ },
+ [router],
+ );
+
+ const setterFunction = useCallback(
+ (newValue: O, paramsConfig: ParamsConfig = { replace: false }) => {
+ if (!config.options.includes(newValue)) return;
+ navigate({ ...(router.query as Record), [config.keyName]: newValue }, paramsConfig);
+ },
+ [config.keyName, config.options, router.query, navigate],
+ );
+
+ const onToggle = useCallback(
+ (paramsConfig: ParamsConfig = { replace: false }) => {
+ if (config.options.length !== 2) {
+ console.warn("onToggle is only available when there are exactly two options");
+ return;
+ }
+
+ const currentOptionIndex = config.options.indexOf(currentValue as O);
+ let nextOption: O;
+
+ if (currentOptionIndex !== -1) {
+ const nextIndex = (currentOptionIndex + 1) % config.options.length;
+ nextOption = config.options[nextIndex];
+ } else {
+ nextOption = config.options[0];
+ }
+
+ setterFunction(nextOption, paramsConfig);
+ },
+ [config.options, currentValue, setterFunction],
+ );
+
+ const clearParam = useCallback(
+ (paramsConfig: ParamsConfig = { replace: false }) => {
+ const newQuery = { ...(router.query as Record) };
+ if (config.keyName in newQuery) {
+ delete newQuery[config.keyName];
+ navigate(newQuery, paramsConfig);
+ }
+ },
+ [router.query, config.keyName, navigate],
+ );
+
+ const capitalizedOptions = useMemo(() => {
+ return config.options.reduce(
+ (acc, option) => {
+ const capitalizedOption = upperFirst(option);
+ const capitalizedKeyName = upperFirst(config.keyName);
+ return Object.assign(acc, {
+ [`is${capitalizedKeyName}${capitalizedOption}`]: currentValue === option,
+ });
+ },
+ {} as { [key: string]: boolean },
+ );
+ }, [currentValue, config.keyName, config.options]);
+
+ return {
+ [config.keyName]: currentValue,
+ [`set${upperFirst(config.keyName)}` as const]: setterFunction,
+ [`toggle${upperFirst(config.keyName)}` as const]: onToggle,
+ [`clear${upperFirst(config.keyName)}` as const]: clearParam,
+ ...capitalizedOptions,
+ } as QueryParamHookResult;
+}
+
+export default useUrlParams;
diff --git a/packages/react-url-query-params/src/next/index.ts b/packages/react-url-query-params/src/next/index.ts
new file mode 100644
index 0000000..472a689
--- /dev/null
+++ b/packages/react-url-query-params/src/next/index.ts
@@ -0,0 +1,2 @@
+export { default as useBatchUrlParams } from "./useBulkUrlParams";
+export { default as useUrlParams } from "./useUrlParams";
diff --git a/packages/react-url-query-params/src/next/useBulkUrlParams.test.tsx b/packages/react-url-query-params/src/next/useBulkUrlParams.test.tsx
new file mode 100644
index 0000000..1ce8009
--- /dev/null
+++ b/packages/react-url-query-params/src/next/useBulkUrlParams.test.tsx
@@ -0,0 +1,58 @@
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import useBulkUrlParams from "./useBulkUrlParams";
+
+let mockPathname = "/";
+let mockSearchParams = new URLSearchParams("");
+const mockPush = vi.fn();
+const mockReplace = vi.fn();
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => mockPathname,
+ useRouter: () => ({ push: mockPush, replace: mockReplace }),
+ useSearchParams: () => mockSearchParams,
+}));
+
+describe("useBulkUrlParams (App Router)", () => {
+ beforeEach(() => {
+ mockPathname = "/";
+ mockSearchParams = new URLSearchParams("");
+ mockPush.mockClear();
+ mockReplace.mockClear();
+ });
+
+ it("initiate hook with all needed params", () => {
+ const { result } = renderHook(() => useBulkUrlParams({ filter: ["active", "inactive"], sort: ["asc", "desc"] }));
+ expect(result.current.isFilterActive).toBe(false);
+ expect(result.current.isFilterInactive).toBe(false);
+ expect(result.current.isSortAsc).toBe(false);
+ expect(result.current.isSortDesc).toBe(false);
+ expect(result.current.set).toBeTypeOf("function");
+ });
+
+ it("set calls router.push with merged params", () => {
+ const { result } = renderHook(() => useBulkUrlParams({ filter: ["active", "inactive"], sort: ["asc", "desc"] }));
+ act(() => result.current.set({ filter: "active", sort: "asc" }));
+ expect(mockPush).toHaveBeenCalledWith("/?filter=active&sort=asc");
+ });
+
+ it("set calls router.replace when replace: true", () => {
+ const { result } = renderHook(() => useBulkUrlParams({ filter: ["active", "inactive"], sort: ["asc", "desc"] }));
+ act(() => result.current.set({ sort: "desc" }, { replace: true }));
+ expect(mockReplace).toHaveBeenCalledWith("/?sort=desc");
+ });
+
+ it("set correct batch data and not override unrelated params", () => {
+ mockSearchParams = new URLSearchParams("unrelated=keep");
+ const { result } = renderHook(() => useBulkUrlParams({ filter: ["active", "inactive"], sort: ["asc", "desc"] }));
+ act(() => result.current.set({ filter: "active" }));
+ expect(mockPush).toHaveBeenCalledWith("/?unrelated=keep&filter=active");
+ });
+
+ it("clearParams removes only declared keys, preserves others", () => {
+ mockSearchParams = new URLSearchParams("filter=inactive&sort=desc&unrelated=keep");
+ const { result } = renderHook(() => useBulkUrlParams({ filter: ["active", "inactive"], sort: ["asc", "desc"] }));
+ act(() => result.current.clearParams());
+ expect(mockPush).toHaveBeenCalledWith("/?unrelated=keep");
+ });
+});
diff --git a/packages/react-url-query-params/src/next/useBulkUrlParams.ts b/packages/react-url-query-params/src/next/useBulkUrlParams.ts
new file mode 100644
index 0000000..38ac104
--- /dev/null
+++ b/packages/react-url-query-params/src/next/useBulkUrlParams.ts
@@ -0,0 +1,87 @@
+"use client";
+
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useCallback, useMemo } from "react";
+import type { Capitalize, ParamsConfig } from "../types.utils";
+import { upperFirst } from "../utils";
+
+type BatchUrlReturnType> = {
+ set: (values: Partial<{ [K in keyof T]: T[K][number] }>, config?: ParamsConfig) => void;
+ clearParams: (config?: ParamsConfig) => void;
+} & {
+ [K in {
+ [Key in keyof T]: {
+ [Val in T[Key][number]]: `is${Capitalize}${Capitalize}`;
+ }[T[Key][number]];
+ }[keyof T]]: boolean;
+};
+
+function useBulkUrlParams>(config: T): BatchUrlReturnType {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const pathname = usePathname();
+
+ type Config = { [K in keyof T]: T[K][number] };
+
+ const navigate = useCallback(
+ (params: URLSearchParams, paramsConfig: ParamsConfig = { replace: false }) => {
+ const url = params.size > 0 ? `${pathname}?${params.toString()}` : pathname;
+ if (paramsConfig.replace) {
+ router.replace(url);
+ } else {
+ router.push(url);
+ }
+ },
+ [pathname, router],
+ );
+
+ const setterFunction = useCallback(
+ (values: Partial, paramsConfig: ParamsConfig = { replace: false }) => {
+ const params = new URLSearchParams(searchParams.toString());
+ Object.entries(values).forEach(([key, value]) => {
+ params.set(key, value as string);
+ });
+ navigate(params, paramsConfig);
+ },
+ [searchParams, navigate],
+ );
+
+ const capitalizedOptions = useMemo(() => {
+ const result = {} as { [key: string]: boolean };
+
+ Object.entries(config).forEach(([key, options]) => {
+ const capitalizedKeyName = upperFirst(key);
+ const currentValue = searchParams.get(key);
+
+ (options as string[]).forEach((option) => {
+ const capitalizedOption = upperFirst(option);
+ Object.assign(result, {
+ [`is${capitalizedKeyName}${capitalizedOption}`]: currentValue === option,
+ });
+ });
+ });
+
+ return result;
+ }, [searchParams, config]);
+
+ const clearParams = useCallback(
+ (paramsConfig: ParamsConfig = { replace: false }) => {
+ const params = new URLSearchParams(searchParams.toString());
+ Object.keys(config).forEach((key) => {
+ if (params.has(key)) {
+ params.delete(key);
+ }
+ });
+ navigate(params, paramsConfig);
+ },
+ [searchParams, config, navigate],
+ );
+
+ return {
+ clearParams,
+ set: setterFunction,
+ ...capitalizedOptions,
+ } as BatchUrlReturnType;
+}
+
+export default useBulkUrlParams;
diff --git a/packages/react-url-query-params/src/next/useUrlParams.test.tsx b/packages/react-url-query-params/src/next/useUrlParams.test.tsx
new file mode 100644
index 0000000..867a7e7
--- /dev/null
+++ b/packages/react-url-query-params/src/next/useUrlParams.test.tsx
@@ -0,0 +1,84 @@
+import { act, renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import useUrlParams from "./useUrlParams";
+
+let mockPathname = "/";
+let mockSearchParams = new URLSearchParams("view=grid");
+const mockPush = vi.fn();
+const mockReplace = vi.fn();
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => mockPathname,
+ useRouter: () => ({
+ push: mockPush,
+ replace: mockReplace,
+ }),
+ useSearchParams: () => mockSearchParams,
+}));
+
+describe("useUrlParams (App Router)", () => {
+ beforeEach(() => {
+ mockPathname = "/";
+ mockSearchParams = new URLSearchParams("view=grid");
+ mockPush.mockClear();
+ mockReplace.mockClear();
+ });
+
+ it("initiate with all provided params following correct interface", () => {
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ expect(result.current.view).toBe("grid");
+ expect(result.current.isViewGrid).toBe(true);
+ expect(result.current.isViewTable).toBe(false);
+ expect(result.current.setView).toBeTypeOf("function");
+ expect(result.current.toggleView).toBeTypeOf("function");
+ expect(result.current.clearView).toBeTypeOf("function");
+ });
+
+ it("reads current value and produced field is", () => {
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ expect(result.current.view).toBe("grid");
+ expect(result.current.isViewGrid).toBe(true);
+ expect(result.current.isViewTable).toBe(false);
+ });
+
+ it("set calls router.push with updated param", () => {
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ act(() => result.current.setView("table"));
+ expect(mockPush).toHaveBeenCalledWith("/?view=table");
+ expect(mockReplace).not.toHaveBeenCalled();
+ });
+
+ it("set calls router.replace when replace: true", () => {
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ act(() => result.current.setView("table", { replace: true }));
+ expect(mockReplace).toHaveBeenCalledWith("/?view=table");
+ expect(mockPush).not.toHaveBeenCalled();
+ });
+
+ it("set ignores value not in options", () => {
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ act(() => result.current.setView("list" as never));
+ expect(mockPush).not.toHaveBeenCalled();
+ });
+
+ it("clear removes param and calls router.push", () => {
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ act(() => result.current.clearView());
+ expect(mockPush).toHaveBeenCalledWith("/");
+ });
+
+ it("toggle alternates between two options", () => {
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table"] }));
+ act(() => result.current.toggleView());
+ expect(mockPush).toHaveBeenCalledWith("/?view=table");
+ });
+
+ it("toggle not possible for more than two options", () => {
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const { result } = renderHook(() => useUrlParams({ keyName: "view", options: ["grid", "table", "dashboard"] }));
+ act(() => result.current.toggleView());
+ expect(mockPush).not.toHaveBeenCalled();
+ expect(warnSpy).toHaveBeenCalled();
+ warnSpy.mockRestore();
+ });
+});
diff --git a/packages/react-url-query-params/src/next/useUrlParams.ts b/packages/react-url-query-params/src/next/useUrlParams.ts
new file mode 100644
index 0000000..888b6b0
--- /dev/null
+++ b/packages/react-url-query-params/src/next/useUrlParams.ts
@@ -0,0 +1,106 @@
+"use client";
+
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useCallback, useMemo } from "react";
+import type { Capitalize, ParamsConfig, QueryParamConfig } from "../types.utils";
+import { upperFirst } from "../utils";
+
+type QueryParamHookResult = {
+ [K in O as `is${Capitalize}${Capitalize}`]: boolean;
+} & {
+ [K in `set${Capitalize}`]: (value: O, config?: ParamsConfig) => void;
+} & {
+ [K in T]: O | null;
+} & {
+ [K in `toggle${Capitalize}`]: (config?: ParamsConfig) => void;
+} & {
+ [K in `clear${Capitalize}`]: (config?: ParamsConfig) => void;
+};
+
+function useUrlParams(config: QueryParamConfig): QueryParamHookResult {
+ // useSearchParams() requires a boundary above this component.
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const pathname = usePathname();
+
+ const currentValue = searchParams.get(config.keyName) as O | null;
+
+ const navigate = useCallback(
+ (params: URLSearchParams, paramsConfig: ParamsConfig = { replace: false }) => {
+ const url = params.size > 0 ? `${pathname}?${params.toString()}` : pathname;
+ if (paramsConfig.replace) {
+ router.replace(url);
+ } else {
+ router.push(url);
+ }
+ },
+ [pathname, router],
+ );
+
+ const setterFunction = useCallback(
+ (newValue: O, paramsConfig: ParamsConfig = { replace: false }) => {
+ if (config.options.includes(newValue)) {
+ const params = new URLSearchParams(searchParams.toString());
+ params.set(config.keyName, newValue);
+ navigate(params, paramsConfig);
+ }
+ },
+ [config.keyName, config.options, searchParams, navigate],
+ );
+
+ const onToggle = useCallback(
+ (paramsConfig: ParamsConfig = { replace: false }) => {
+ if (config.options.length !== 2) {
+ console.warn("onToggle is only available when there are exactly two options");
+ return;
+ }
+
+ const currentOptionIndex = config.options.indexOf(currentValue as O);
+ let nextOption: O;
+
+ if (currentOptionIndex !== -1) {
+ const nextIndex = (currentOptionIndex + 1) % config.options.length;
+ nextOption = config.options[nextIndex];
+ } else {
+ nextOption = config.options[0];
+ }
+
+ setterFunction(nextOption, paramsConfig);
+ },
+ [config.options, currentValue, setterFunction],
+ );
+
+ const clearParam = useCallback(
+ (paramsConfig: ParamsConfig = { replace: false }) => {
+ const params = new URLSearchParams(searchParams.toString());
+ if (params.has(config.keyName)) {
+ params.delete(config.keyName);
+ navigate(params, paramsConfig);
+ }
+ },
+ [searchParams, config.keyName, navigate],
+ );
+
+ const capitalizedOptions = useMemo(() => {
+ return config.options.reduce(
+ (acc, option) => {
+ const capitalizedOption = upperFirst(option);
+ const capitalizedKeyName = upperFirst(config.keyName);
+ return Object.assign(acc, {
+ [`is${capitalizedKeyName}${capitalizedOption}`]: searchParams.get(config.keyName) === option,
+ });
+ },
+ {} as { [key: string]: boolean },
+ );
+ }, [searchParams, config.keyName, config.options]);
+
+ return {
+ [config.keyName]: currentValue,
+ [`set${upperFirst(config.keyName)}` as const]: setterFunction,
+ [`toggle${upperFirst(config.keyName)}` as const]: onToggle,
+ [`clear${upperFirst(config.keyName)}` as const]: clearParam,
+ ...capitalizedOptions,
+ } as QueryParamHookResult;
+}
+
+export default useUrlParams;
diff --git a/packages/react-url-query-params/src/useBulkUrlParams.ts b/packages/react-url-query-params/src/useBulkUrlParams.ts
index b233ef8..1258962 100644
--- a/packages/react-url-query-params/src/useBulkUrlParams.ts
+++ b/packages/react-url-query-params/src/useBulkUrlParams.ts
@@ -4,8 +4,8 @@ import type { Capitalize, ParamsConfig } from "./types.utils";
import { upperFirst } from "./utils";
type BatchUrlReturnType> = {
- set: (values: Partial<{ [K in keyof T]: T[K][number] }>) => void;
- clearParams: () => void;
+ set: (values: Partial<{ [K in keyof T]: T[K][number] }>, config?: ParamsConfig) => void;
+ clearParams: (config?: ParamsConfig) => void;
} & {
[K in {
[Key in keyof T]: {
diff --git a/packages/react-url-query-params/src/useUrlParams.ts b/packages/react-url-query-params/src/useUrlParams.ts
index f0c4f6b..8e708e9 100644
--- a/packages/react-url-query-params/src/useUrlParams.ts
+++ b/packages/react-url-query-params/src/useUrlParams.ts
@@ -6,13 +6,13 @@ import { upperFirst } from "./utils";
type QueryParamHookResult = {
[K in O as `is${Capitalize}${Capitalize}`]: boolean;
} & {
- [K in `set${Capitalize}`]: (value: O) => void;
+ [K in `set${Capitalize}`]: (value: O, config?: ParamsConfig) => void;
} & {
[K in T]: O | null;
} & {
- [K in `toggle${Capitalize}`]: () => void;
+ [K in `toggle${Capitalize}`]: (config?: ParamsConfig) => void;
} & {
- [K in `clear${Capitalize}`]: () => void;
+ [K in `clear${Capitalize}`]: (config?: ParamsConfig) => void;
};
function useUrlParams(config: QueryParamConfig): QueryParamHookResult {
diff --git a/packages/react-url-query-params/tsup.config.mjs b/packages/react-url-query-params/tsup.config.mjs
index 3c3864a..9461fc8 100644
--- a/packages/react-url-query-params/tsup.config.mjs
+++ b/packages/react-url-query-params/tsup.config.mjs
@@ -3,7 +3,12 @@ import { defineConfig } from "tsup";
export default defineConfig({
clean: true,
dts: true,
- entry: ["src/index.ts"],
+ entry: {
+ index: "src/index.ts",
+ "next/index": "src/next/index.ts",
+ "next-pages/index": "src/next-pages/index.ts",
+ },
+ external: ["next", "react", "react-dom", "react-router-dom"],
format: ["esm", "cjs"],
sourcemap: false,
target: "es2020",