diff --git a/.changeset/tame-vans-retire.md b/.changeset/tame-vans-retire.md new file mode 100644 index 0000000..8a702ec --- /dev/null +++ b/.changeset/tame-vans-retire.md @@ -0,0 +1,6 @@ +--- +"@use-search-params-state/react": patch +"@use-search-params-state/next": patch +--- + +Added generic type parameter to the useSearchParamsState hooks diff --git a/apps/playground/src/app/page.tsx b/apps/playground/src/app/page.tsx index 0e3c73f..b96a26a 100644 --- a/apps/playground/src/app/page.tsx +++ b/apps/playground/src/app/page.tsx @@ -1,5 +1,5 @@ import { redirect } from "next/navigation"; export default function IndexPage() { - redirect("/default"); + redirect("/basic"); } diff --git a/apps/playground/src/app/sidebar.tsx b/apps/playground/src/app/sidebar.tsx index 1052492..d998de1 100644 --- a/apps/playground/src/app/sidebar.tsx +++ b/apps/playground/src/app/sidebar.tsx @@ -12,6 +12,9 @@ export const Sidebar = () => { With default values With Zod + + With weak type safety + diff --git a/apps/playground/src/app/with-weak-typesafety/page.tsx b/apps/playground/src/app/with-weak-typesafety/page.tsx new file mode 100644 index 0000000..5c791b3 --- /dev/null +++ b/apps/playground/src/app/with-weak-typesafety/page.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { SetKeyValueInputs } from "@/components/set-key-value-inputs"; +import { Alert } from "@/components/ui/alert"; + +import { useSearchParamsState } from "@use-search-params-state/next"; + +interface SearchParamsType { + page: string; + search?: string; +} + +export default function Page() { + const [state, setState] = useSearchParamsState({ + defaultValues: { + page: "1", + }, + }); + + return ( +
+ + { + "Check source code for this page. It has a generic type parameter passed to useSearchParamsState and enforces usage of the state inline with the provided type." + } + + + setState(k as keyof SearchParamsType, v)} + /> + +
{JSON.stringify(state, null, 2)}
+
+ ); +} diff --git a/apps/playground/src/app/with-zod/page.tsx b/apps/playground/src/app/with-zod/page.tsx index 8b7cdc7..07473e4 100644 --- a/apps/playground/src/app/with-zod/page.tsx +++ b/apps/playground/src/app/with-zod/page.tsx @@ -1,3 +1,3 @@ export default function Page() { - return
with zod
; + return
TODO: with zod
; } diff --git a/packages/next/src/example.tsx b/packages/next/src/example.tsx new file mode 100644 index 0000000..b1a1d30 --- /dev/null +++ b/packages/next/src/example.tsx @@ -0,0 +1,18 @@ +import { useSearchParamsState } from "./index"; + +const Component = () => { + const [state, setState] = useSearchParamsState<{ + greeting?: string; + hello: string; + }>(); + + const handleClick = () => { + state.greeting === "hello" + ? setState("greeting", "world") + : setState("greeting", "hello"); + }; + + return ; +}; + +; diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index d57d6fe..0671f63 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -3,7 +3,13 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useSearchParamsState as useSearchParamsStateReact } from "@use-search-params-state/react"; import type { UseSearchParamsStateOptions } from "@use-search-params-state/react"; -export const useSearchParamsState = (opts?: UseSearchParamsStateOptions) => { +export const useSearchParamsState = < + State extends + | { [K in keyof State]: string } + | Record = Record, +>( + opts?: UseSearchParamsStateOptions, +) => { const searchParams = useSearchParams(); const router = useRouter(); const pathname = usePathname(); @@ -12,7 +18,7 @@ export const useSearchParamsState = (opts?: UseSearchParamsStateOptions) => { router.push(pathname + "?" + newSearchParams.toString()); }; - return useSearchParamsStateReact({ + return useSearchParamsStateReact({ searchParams, setSearchParams, ...opts, diff --git a/packages/react/src/example.tsx b/packages/react/src/example.tsx new file mode 100644 index 0000000..aba2606 --- /dev/null +++ b/packages/react/src/example.tsx @@ -0,0 +1,54 @@ +import { useState } from "react"; + +import { useSearchParamsState } from "./index"; + +const Component1 = () => { + const [searchParams, setSearchParams] = useState(new URLSearchParams()); + + const [state, setState] = useSearchParamsState<{ + greeting?: string; + hello: string; + }>({ + searchParams, + setSearchParams: (newSearchParams) => { + console.log("newSearchParams", newSearchParams); + setSearchParams(newSearchParams); + }, + }); + + const handleClick = () => { + state.greeting === "hello" + ? setState("greeting", "world") + : setState("greeting", "hello"); + }; + + return ; +}; + +; + +const Comp2 = () => { + const [searchParams, setSearchParams] = useState(new URLSearchParams()); + + const [state, setState] = useSearchParamsState<{ + page: string; + hello: string; + }>({ + defaultValues: { + hello: "world", + }, + searchParams, + setSearchParams: (newSearchParams) => { + console.log("newSearchParams", newSearchParams); + setSearchParams(newSearchParams); + }, + }); + + const handleClick = () => { + state.page === "1" ? setState("page", "2") : setState("page", "1"); + }; + + return ; +}; + +; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 3d7af7f..e99cf81 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,13 +1,9 @@ import { useState } from "react"; -import type { ReadonlyURLSearchParams } from "next/navigation"; -// export type SetStateType, R extends ZodRawShape> = < -// T extends ZodInfer, -// K extends keyof T -// >( -// key: K extends keyof T ? K : string, -// value: K extends keyof T ? T[K] : string -// ) => void; +import type { UseSearchParamsStateParams } from "./types"; +import { getSearchParams, searchParamsToObject } from "./utils"; + +export * from "./types"; // const getDefaultsFromSchema = , R extends ZodRawShape>( // schema: S @@ -23,167 +19,18 @@ import type { ReadonlyURLSearchParams } from "next/navigation"; // return newDefaultValuesObject; // }; -// function partialObjectEqual( -// obj1: Record, -// obj2: Record -// ) { -// const keys1 = Object.keys(obj1); -// const keys2 = Object.keys(obj2); - -// const overlappingKeys = keys1.filter((k) => keys2.includes(k)); - -// for (const key of overlappingKeys) { -// if (obj1[key] == obj2[key]) continue; -// else return false; -// } - -// return true; -// } - -// const initOptions = (options?: UseSearchParamsStateOptions) => { -// return { -// removeFalsyValues: true, -// ...options, -// } satisfies Required; -// }; - -// const useDefaultValues = , R extends ZodRawShape>( -// schema: S -// ) => { -// return useMemo(() => getDefaultValueObject(schema), [schema]); -// }; - -// const validateSchemaFormat = , R extends ZodRawShape>( -// schema: S -// ) => { -// if (schema._def.typeName !== "ZodObject") { -// throw new Error( -// "Schema has to be a Zod object. Use z.object() to define your schema." -// ); -// } -// }; - -// const getStateFromParams = < -// S extends ZodObject, -// R extends ZodRawShape, -// T extends ZodInfer -// >( -// schema: S, -// params: URLSearchParams | ReadonlyURLSearchParams -// ) => { -// return schema.parse(createObjectFromSearchParams(params)) as T; -// }; - -/** - * Requirements list - * - * - [x] Initial state comes from search params. - * - [x] Initial state is set to default values if not present in search params (zod). - * - [x] Hook exposes current state and setState function. - * - [x] setState function updates state and calls saveSearchParams function after the state is updated. - * - [ ] ??? State includes parameters in zod schema, extra props are not added or removed from searchParams. - * - [ ] If no params are provided, they are not set until state is changed. - * - [ ] saveSearchParams function updates search params outside of the hook. - * - [ ] Optional parameters. - * - [ ] SSR function to parse searchParams once. - * - [ ] Zod schema should be optional, but if provided, it has to be type-safe. - * - [x] Zod schema has to be an object first, even for single param. - * - [ ] Array field support, allowing to have multi-value parameters. - */ - -export interface UseSerachParamsStateBase { - searchParams: URLSearchParams | ReadonlyURLSearchParams; - setSearchParams: (newSearchParams: URLSearchParams) => void; -} - -export interface UseSearchParamsStateOptions { - /** - * Default values will be used in the case that the search param - * you're accessing does not contain a value. We recommend always - * providing default values for your search params. - */ - defaultValues?: Record; - - /** - * Zod schema - */ - // TODO: Implement Zod Validation - // zodSchema?: ZodSchema ; - - /** - * If true, default values will be removed from the search params. - * @default true - */ - removeDefaultValues?: boolean; - - /** - * If true, falsy values will be removed from the search params. - * @default true - */ - removeFalsyValues?: boolean; - - /** - * If true, keys that were initially present in the search params - * will be preserved even if the removeDefaultValues and - * removeFalsyValues options are set to true. - * @default false - */ - preserveInitialKeys?: boolean; - - /** - * If true, keys will be sorted alphabetically. - * @default true - */ - sortKeys?: boolean; -} - -// const TestSchema = z.object({ -// test1: z.coerce.string() -// }) - -// const testComponent = () => { -// const searchParams = new URLSearchParams(); -// const setSearchParams = console.log; - -// const [state, setState] = useSearchParamsState({ -// searchParams, -// setSearchParams, -// defaultValues: { -// something: 'asdf' -// }, -// // zodSchema: TestSchema -// }) - -// const hello = state.hello; - -// return null; -// } - -const searchParamsToObject = ( - sp: URLSearchParams | ReadonlyURLSearchParams, -): Record => { - const newObj: Record = {}; - - for (const key of Array.from(sp.keys())) { - const value = sp.get(key); - if (value === null) continue; - newObj[key] = value; - } - - return newObj; -}; - -export type UseSearchParamsStateParams = UseSerachParamsStateBase & - UseSearchParamsStateOptions; - export const useSearchParamsState = < + State extends + | { [K in keyof State]: string } + | Record = Record, // ZodSchema extends ZodObject, // ZodSchemaRaw extends ZodRawShape, // ZodSchemaType extends ZodInfer, - Params extends UseSearchParamsStateParams, + Params extends + UseSearchParamsStateParams = UseSearchParamsStateParams, >( p: Params, -): [Record, (key: string, value: string) => void] => { +): [State, (key: keyof State, value: string) => void] => { // Configure default values. const opts: Params = { removeDefaultValues: true, @@ -201,14 +48,14 @@ export const useSearchParamsState = < const [initiallySetKeys] = useState(Array.from(p.searchParams.keys())); - const setState = (key: string, value: string) => { + const setState = (key: keyof State, value: string) => { const newObject = { ...spObject, [key]: value, }; const newSearchParams = getSearchParams({ - newObject, + newObject: newObject as unknown as State, options: opts, initiallySetKeys, }); @@ -216,63 +63,5 @@ export const useSearchParamsState = < p.setSearchParams(newSearchParams); }; - return [spObject, setState]; -}; - -const getSearchParams = ({ - newObject, - options, - initiallySetKeys, -}: { - newObject: Record; - options: UseSearchParamsStateParams; - initiallySetKeys: string[]; -}) => { - const newSearchParams = new URLSearchParams(); - - Object.keys(newObject).forEach((k) => { - const v = newObject[k]; - if (typeof v === "undefined") return; - - // If the key was set initially, we want to set save it regardless of value. - if (options.preserveInitialKeys && initiallySetKeys.includes(k)) { - newSearchParams.set(k, v); - return; - } - - // If the value is falsy and we want to remove falsy values, skip it. - if (options.removeFalsyValues && !v) return; - - // If the value is the default value and we want to remove default values, skip it. - if (options.removeDefaultValues && v === options.defaultValues?.[k]) return; - - newSearchParams.set(k, v); - }); - - if (options.sortKeys) { - newSearchParams.sort(); - } - - return newSearchParams; + return [spObject as unknown as State, setState]; }; - -// export const createQueryStringFromSchema = < -// S extends ZodObject, -// R extends ZodRawShape -// >( -// searchParams: URLSearchParams | ReadonlyURLSearchParams, -// schema: S -// ): URLSearchParams | undefined => { -// const stateKeys = Object.keys(schema.shape); -// if (!stateKeys.length) return undefined; - -// const newParams = new URLSearchParams(); -// const stateObject = createObjectFromSearchParams(searchParams); - -// stateKeys.forEach((key) => { -// const value = schema.shape[key as any]?.parse(stateObject[key]); -// newParams.set(key, value); -// }); - -// return newParams; -// }; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts new file mode 100644 index 0000000..df81e77 --- /dev/null +++ b/packages/react/src/types.ts @@ -0,0 +1,50 @@ +import type { ReadonlyURLSearchParams } from "next/navigation"; + +export interface UseSearchParamsStateBase { + searchParams: URLSearchParams | ReadonlyURLSearchParams; + setSearchParams: (newSearchParams: URLSearchParams) => void; +} + +export interface UseSearchParamsStateOptions { + /** + * Default values will be used in the case that the search param + * you're accessing does not contain a value. We recommend always + * providing default values for your search params. + */ + defaultValues?: Record; + + /** + * Zod schema + */ + // TODO: Implement Zod Validation + // zodSchema?: ZodSchema ; + + /** + * If true, default values will be removed from the search params. + * @default true + */ + removeDefaultValues?: boolean; + + /** + * If true, falsy values will be removed from the search params. + * @default true + */ + removeFalsyValues?: boolean; + + /** + * If true, keys that were initially present in the search params + * will be preserved even if the removeDefaultValues and + * removeFalsyValues options are set to true. + * @default false + */ + preserveInitialKeys?: boolean; + + /** + * If true, keys will be sorted alphabetically. + * @default true + */ + sortKeys?: boolean; +} + +export type UseSearchParamsStateParams = UseSearchParamsStateBase & + UseSearchParamsStateOptions; diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts new file mode 100644 index 0000000..8ae1c80 --- /dev/null +++ b/packages/react/src/utils.ts @@ -0,0 +1,54 @@ +import type { ReadonlyURLSearchParams } from "next/navigation"; + +import type { UseSearchParamsStateParams } from "./types"; + +export const getSearchParams = ({ + newObject, + options, + initiallySetKeys, +}: { + newObject: Record; + options: UseSearchParamsStateParams>; + initiallySetKeys: string[]; +}) => { + const sp = new URLSearchParams(); + + Object.keys(newObject).forEach((k) => { + const v = newObject[k]; + if (typeof v === "undefined") return; + + // If the key was set initially, we want to set save it regardless of value. + if (options.preserveInitialKeys && initiallySetKeys.includes(k)) { + sp.set(k, v); + return; + } + + // If the value is falsy and we want to remove falsy values, skip it. + if (options.removeFalsyValues && !v) return; + + // If the value is the default value and we want to remove default values, skip it. + if (options.removeDefaultValues && v === options.defaultValues?.[k]) return; + + sp.set(k, v); + }); + + if (options.sortKeys) { + sp.sort(); + } + + return sp; +}; + +export const searchParamsToObject = ( + sp: URLSearchParams | ReadonlyURLSearchParams, +): Record => { + const newObj: Record = {}; + + for (const key of Array.from(sp.keys())) { + const value = sp.get(key); + if (value === null) continue; + newObj[key] = value; + } + + return newObj; +};