'use client';
import { useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useMemo, useOptimistic, useTransition } from 'react';
import { z } from 'zod';
type CustomToStringFn<T> = (value: T) => string | null | undefined;
/**
* A custom hook that provides validated search parameters based on a Zod validator.
*
* @template T - The ZodType used for validation.
* @param {T} zodValidator - The Zod validator to validate the search parameters. *Make sure to provide a default fallback.*
* @param {CustomToStringFn<z.infer<T>[keyof z.infer<T>]>} [options.customToString] - A custom function to convert the value to a string used in the URL.
* @param {string} [options.pathname] - The pathname to use when updating the URL.
* @param {boolean} [options.shallow] - If true, the URL will be updated without reloading rsc's.
* @returns {[z.infer<T>, (value: z.infer<T>) => void, boolean]} - A tuple containing the validated parameters, a function to set the value (like what useState returns), and a boolean indicating if the transition is pending.
*/
export function useValidatedSearchParams<T extends z.ZodType>(
zodValidator: T,
options?: {
customToString?: CustomToStringFn<z.infer<T>[keyof z.infer<T>]>;
pathname?: string;
shallow?: boolean;
}
): [
z.infer<T> | undefined,
(value: z.infer<T>, resetKeys?: string[]) => void,
boolean
] {
const [pending, startTransition] = useTransition();
const searchParams = useSearchParams();
const router = useRouter();
const validatedParams = useMemo(() => {
const params = fromEntries(searchParams?.entries() ?? []);
const parsed = zodValidator.safeParse(params);
if (parsed.success) {
return parsed.data;
}
return undefined;
}, [searchParams, zodValidator]);
const [optimisticParams, setOptimisticParams] =
useOptimistic(validatedParams);
const setValue = useCallback(
(newValue: z.infer<T>, resetKeys?: string[]) => {
startTransition(() => {
setOptimisticParams(newValue);
const url = updateSearchParams<T>(
newValue,
resetKeys,
options?.customToString
);
url.pathname = options?.pathname ?? url.pathname;
if (options?.shallow) {
window.history.replaceState({}, '', url.href);
} else {
router.replace(url.href, { scroll: false });
}
});
},
[
options?.customToString,
options?.pathname,
options?.shallow,
router,
setOptimisticParams,
startTransition,
]
);
return [optimisticParams, setValue, pending];
}
function fromEntries(entries: Iterable<[string, string]>) {
const output: Record<string, string | string[]> = {};
for (const [key, value] of entries) {
if (output[key] === undefined) {
output[key] = value;
} else if (Array.isArray(output[key])) {
// @ts-expect-error isArray so push exists
output[key].push(value);
} else {
// @ts-expect-error output[key] is a string
output[key] = [output[key], value];
}
}
return output;
}
function updateSearchParams<T extends z.ZodType>(
newValue: z.TypeOf<T>,
resetKeys?: string[],
customToString?: CustomToStringFn<z.infer<T>>
) {
const url = new URL(window.location.href);
for (const [key, val] of Object.entries<T>(newValue)) {
if (Array.isArray(val)) {
url.searchParams.delete(key);
for (const value of val) {
const strValue = customToString?.(value) ?? value?.toString();
if (strValue !== undefined && strValue !== null) {
url.searchParams.append(key, strValue);
}
}
} else {
const strValue = customToString?.(val) ?? val?.toString();
if (strValue !== undefined && strValue !== null) {
url.searchParams.set(key, strValue);
} else {
url.searchParams.delete(key);
}
}
}
resetKeys?.forEach((key) => url.searchParams.delete(key));
return url;
}