From 0794f6f93ae7bc9e69bf3f7e667c6fccb446c92e Mon Sep 17 00:00:00 2001 From: pkuzina Date: Thu, 26 Feb 2026 16:22:46 +0200 Subject: [PATCH 1/2] added support for nextjs --- .husky/pre-commit | 3 +- apps/playground/src/App.tsx | 46 +- package-lock.json | 896 +++++++++++++++++- package.json | 5 +- packages/react-url-query-params/README.md | 153 ++- packages/react-url-query-params/package.json | 31 +- .../src/next-pages/index.ts | 2 + .../src/next-pages/useBulkUrlParams.test.tsx | 73 ++ .../src/next-pages/useBulkUrlParams.ts | 79 ++ .../src/next-pages/useUrlParams.test.tsx | 107 +++ .../src/next-pages/useUrlParams.ts | 99 ++ .../react-url-query-params/src/next/index.ts | 2 + .../src/next/useBulkUrlParams.test.tsx | 58 ++ .../src/next/useBulkUrlParams.ts | 87 ++ .../src/next/useUrlParams.test.tsx | 84 ++ .../src/next/useUrlParams.ts | 106 +++ .../src/useBulkUrlParams.ts | 4 +- .../src/useUrlParams.ts | 6 +- .../react-url-query-params/tsup.config.mjs | 7 +- 19 files changed, 1805 insertions(+), 43 deletions(-) create mode 100644 packages/react-url-query-params/src/next-pages/index.ts create mode 100644 packages/react-url-query-params/src/next-pages/useBulkUrlParams.test.tsx create mode 100644 packages/react-url-query-params/src/next-pages/useBulkUrlParams.ts create mode 100644 packages/react-url-query-params/src/next-pages/useUrlParams.test.tsx create mode 100644 packages/react-url-query-params/src/next-pages/useUrlParams.ts create mode 100644 packages/react-url-query-params/src/next/index.ts create mode 100644 packages/react-url-query-params/src/next/useBulkUrlParams.test.tsx create mode 100644 packages/react-url-query-params/src/next/useBulkUrlParams.ts create mode 100644 packages/react-url-query-params/src/next/useUrlParams.test.tsx create mode 100644 packages/react-url-query-params/src/next/useUrlParams.ts 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)}

+
+ + + + + +
+
+ +
+

useBatchUrlParams — multiple params

+

isFilterActive: {String(isFilterActive)} | isFilterInactive: {String(isFilterInactive)}

+

isSortAsc: {String(isSortAsc)} | isSortDesc: {String(isSortDesc)}

+
+ + + + +
+
+
+ ); +} + function App() { const [count, setCount] = useState(0); @@ -61,6 +104,7 @@ function App() { return ( <> +
}> + + + ); +} +``` + +### 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}

+ + + + + {/* Replace history entry instead of adding a new one */} + + {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 ( +
+ + + + {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...’}

+ + + + + {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 ( +
+ + + + {isFilterActive && Showing active} +
+ ); +} +``` + +--- + +## Usage (react-router-dom) ![Demo of react-url-query-params](./docs/demo.gif) 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