Skip to content

Commit a41c83f

Browse files
committed
added use-search-params-state with example (examples/with-use-search-params-state)
1 parent 5d93385 commit a41c83f

111 files changed

Lines changed: 38581 additions & 858 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

assets/nextjs/hooks.ts

Lines changed: 236 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,20 @@ import {
44
useSearchParams as useNextSearchParams
55
} from "next/navigation";
66
import { z } from "zod";
7+
import { useCallback, useEffect, useMemo, useRef } from "react";
78

8-
import { RouteBuilder } from "./makeRoute";
9+
import type { RouteBuilder } from "./makeRoute";
10+
import {
11+
ZodArray,
12+
type ZodTypeAny,
13+
ZodOptional,
14+
ZodNullable,
15+
ZodDefault,
16+
ZodEffects
17+
} from "zod";
918

19+
import debounce from "lodash.debounce";
20+
import throttle from "lodash.throttle";
1021
const emptySchema = z.object({});
1122

1223
type PushOptions = Parameters<ReturnType<typeof useRouter>["push"]>[1];
@@ -15,59 +26,265 @@ export function usePush<
1526
Params extends z.ZodSchema,
1627
Search extends z.ZodSchema = typeof emptySchema
1728
>(builder: RouteBuilder<Params, Search>) {
18-
const { push } = useRouter();
29+
const router = useRouter();
1930
return (
2031
p: z.input<Params>,
2132
search?: z.input<Search>,
2233
options?: PushOptions
2334
) => {
24-
push(builder(p, search), options);
35+
router.push(builder(p, search), options);
2536
};
2637
}
2738

39+
type UseParamsConfig = {
40+
partial?: boolean;
41+
};
42+
const defaultUseParamsConfig = {
43+
partial: false
44+
} as const satisfies UseParamsConfig;
45+
2846
export function useParams<
29-
Params extends z.ZodSchema,
30-
Search extends z.ZodSchema = typeof emptySchema
31-
>(builder: RouteBuilder<Params, Search>): z.output<Params> {
32-
const res = builder.paramsSchema.safeParse(useNextParams());
47+
Params extends z.AnyZodObject,
48+
Search extends z.AnyZodObject = typeof emptySchema,
49+
TConfig extends UseParamsConfig = typeof defaultUseParamsConfig
50+
>(
51+
builder: RouteBuilder<Params, Search>,
52+
_config?: TConfig
53+
): TConfig["partial"] extends true
54+
? Partial<z.output<Params>>
55+
: z.output<Params> {
56+
const nextParams = useNextParams();
57+
const shouldUsePartial = _config?.partial ?? defaultUseParamsConfig.partial;
58+
const targetSchema = shouldUsePartial
59+
? builder.paramsSchema.partial()
60+
: builder.paramsSchema;
61+
const isSafeParsedWithPartial = builder.paramsSchema
62+
.partial()
63+
.safeParse(nextParams).success;
64+
const res = targetSchema.safeParse(nextParams);
3365
if (!res.success) {
3466
throw new Error(
35-
`Invalid route params for route ${builder.routeName}: ${res.error.message}`
67+
[
68+
`Invalid route params for route ${builder.routeName}: ${res.error.message}.`,
69+
`${isSafeParsedWithPartial ? "ℹ️ If you wanted to use partial params, pass {partial:true} as second parameter." : ""}`
70+
]
71+
.filter(Boolean)
72+
.join(" ")
3673
);
3774
}
3875
return res.data;
3976
}
4077

4178
export function useSearchParams<
42-
Params extends z.ZodSchema,
43-
Search extends z.ZodSchema = typeof emptySchema
44-
>(builder: RouteBuilder<Params, Search>): z.output<Search> {
45-
const res = builder.searchSchema!.safeParse(
46-
convertURLSearchParamsToObject(useNextSearchParams())
79+
Params extends z.AnyZodObject,
80+
Search extends z.AnyZodObject = typeof emptySchema
81+
>(
82+
builder: RouteBuilder<Params, Search>,
83+
config?: UseParamsConfig
84+
): z.output<Search> {
85+
const searchSchema = useMemo(
86+
() => (config?.partial ? builder.searchSchema : builder.searchSchema),
87+
[config?.partial, builder.searchSchema]
88+
);
89+
90+
const rawParams = convertURLSearchParamsToObject(
91+
useNextSearchParams(),
92+
searchSchema
4793
);
94+
95+
const res = builder.searchSchema.safeParse(rawParams);
4896
if (!res.success) {
4997
throw new Error(
5098
`Invalid search params for route ${builder.routeName}: ${res.error.message}`
5199
);
52100
}
53101
return res.data;
54102
}
103+
export function useSearchParamsState<
104+
Params extends z.AnyZodObject,
105+
Search extends z.AnyZodObject = typeof emptySchema
106+
>(builder: RouteBuilder<Params, Search>, config?: UseParamsConfig) {
107+
const _searchParams = useSearchParams(builder, config);
108+
const push = usePush(builder);
109+
const params = useParams(builder, config);
110+
const searchParams = useMemo(() => _searchParams, [_searchParams]);
111+
112+
/**
113+
* @param newValues - The new values to set. If you want to unset a value, pass `null`. If you want to dynamically set or not set a value, use `undefined` because `undefined` values will be omitted.
114+
*/
115+
const setSearchParams = useCallback(
116+
(
117+
newValues: Partial<{
118+
[K in keyof z.output<Search>]: z.output<Search>[K] | null | undefined;
119+
}>
120+
) => {
121+
if (Object.keys(newValues).every((val) => val === undefined)) {
122+
return;
123+
}
124+
const updatedValues: Partial<z.output<Search>> = builder.searchSchema
125+
.partial()
126+
.parse({ ..._searchParams, ...newValues });
127+
for (const [key, value] of Object.entries(updatedValues)) {
128+
if (value === null) {
129+
delete updatedValues[key];
130+
}
131+
132+
if (
133+
value === "" &&
134+
builder.searchSchema.shape[key] instanceof ZodOptional
135+
) {
136+
delete updatedValues[key];
137+
}
138+
}
139+
140+
push(params, updatedValues);
141+
},
142+
[_searchParams, push, params, builder.searchSchema]
143+
);
144+
145+
const debouncedSetSearchParams = useDebounceCallback(
146+
setSearchParams
147+
) as typeof setSearchParams;
148+
const resetAllValues = useCallback(() => {
149+
push(params, builder.searchSchema.parse({}) as z.output<Search>);
150+
}, [push, params, builder]);
151+
152+
return {
153+
searchParams,
154+
setSearchParams,
155+
resetAllValues,
156+
debouncedSetSearchParams
157+
} as const;
158+
}
159+
export type DebounceOptions = {
160+
leading?: boolean;
161+
trailing?: boolean;
162+
maxWait?: number;
163+
delay?: number;
164+
// debounceOrThrottle?: "debounce" | "throttle";
165+
};
166+
type DebounceCallbackParam = DebounceOptions & {
167+
debounceOrThrottle?: "debounce" | "throttle";
168+
};
169+
170+
type ControlFunctions = {
171+
cancel: () => void;
172+
flush: () => void;
173+
isPending: () => boolean;
174+
};
175+
176+
export type DebouncedState<T extends (...args: any) => ReturnType<T>> = ((
177+
...args: Parameters<T>
178+
) => ReturnType<T> | undefined) &
179+
ControlFunctions;
180+
181+
const defaultDebounceCallbackParam: DebounceCallbackParam = {
182+
delay: 500,
183+
debounceOrThrottle: "debounce"
184+
};
185+
export function useDebounceCallback<T extends (...args: any) => ReturnType<T>>(
186+
func: T,
187+
_options: DebounceCallbackParam = {}
188+
): DebouncedState<T> {
189+
const options = useMemo(
190+
() => ({ ...defaultDebounceCallbackParam, ..._options }),
191+
[_options]
192+
);
193+
const debounceOrThrottle = useMemo(
194+
() => options.debounceOrThrottle,
195+
[options.debounceOrThrottle]
196+
);
197+
const delay = useMemo(() => options.delay, [options.delay]);
198+
const debounceOptions = useMemo(() => {
199+
return {
200+
...options,
201+
leading: options.leading ?? false,
202+
debounceOrThrottle,
203+
delay
204+
};
205+
}, [options, debounceOrThrottle, delay]);
206+
const debouncedFunc = useRef<ReturnType<typeof debounce>>(undefined);
207+
208+
useEffect(() => {
209+
if (debouncedFunc.current) {
210+
debouncedFunc.current.cancel();
211+
}
212+
});
213+
214+
const debounced = useMemo(() => {
215+
const debouncedFuncInstance =
216+
debounceOrThrottle === "throttle"
217+
? throttle(func, delay, debounceOptions)
218+
: debounce(func, delay, debounceOptions);
219+
220+
const wrappedFunc: DebouncedState<T> = (...args: Parameters<T>) => {
221+
return debouncedFuncInstance(...args);
222+
};
223+
224+
wrappedFunc.cancel = () => {
225+
debouncedFuncInstance.cancel();
226+
};
227+
228+
wrappedFunc.isPending = () => {
229+
return !!debouncedFunc.current;
230+
};
231+
232+
wrappedFunc.flush = () => {
233+
return debouncedFuncInstance.flush();
234+
};
235+
236+
return wrappedFunc;
237+
}, [debounceOrThrottle, func, delay, debounceOptions]);
238+
239+
// Update the debounced function ref whenever func, wait, or options change
240+
useEffect(() => {
241+
debouncedFunc.current = debounce(func, delay, debounceOptions);
242+
}, [func, delay, debounceOptions]);
243+
244+
return debounced;
245+
}
55246

56247
function convertURLSearchParamsToObject(
57-
params: Readonly<URLSearchParams> | null
248+
params: Readonly<URLSearchParams> | null,
249+
schema: z.ZodTypeAny
58250
): Record<string, string | string[]> {
59251
if (!params) {
60252
return {};
61253
}
62254

63255
const obj: Record<string, string | string[]> = {};
64-
// @ts-ignore
65-
for (const [key, value] of params.entries()) {
66-
if (params.getAll(key).length > 1) {
67-
obj[key] = params.getAll(key);
256+
const arrayKeys = getArrayKeysFromZodSchema(schema);
257+
const uniqueKeys = [...new Set(params.keys())];
258+
for (const key of uniqueKeys) {
259+
if (arrayKeys.includes(key)) {
260+
obj[key] = params.getAll(key).filter(Boolean);
68261
} else {
69-
obj[key] = value;
262+
const value = params.get(key);
263+
if (value) {
264+
obj[key] = value;
265+
}
70266
}
71267
}
72268
return obj;
73269
}
270+
export function getArrayKeysFromZodSchema(schema: z.ZodTypeAny): string[] {
271+
if (!(schema instanceof z.ZodObject)) return [];
272+
return Object.entries(schema.shape).flatMap(([key, value]) => {
273+
const unwrapped = unwrapZodType(
274+
value as Parameters<typeof unwrapZodType>[0]
275+
);
276+
return unwrapped instanceof ZodArray ? [key] : [];
277+
});
278+
}
279+
function unwrapZodType(schema: ZodTypeAny): ZodTypeAny {
280+
const unwrappableInstances = [
281+
ZodOptional,
282+
ZodNullable,
283+
ZodDefault,
284+
ZodEffects
285+
];
286+
if (unwrappableInstances.some((instance) => schema instanceof instance)) {
287+
return unwrapZodType(schema._def.innerType || schema._def.schema);
288+
}
289+
return schema;
290+
}

assets/nextjs/makeRoute.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
Derived from: https://www.flightcontrol.dev/blog/fix-nextjs-routing-to-have-full-type-safety
33
*/
4-
import { z } from "zod";
4+
import { type AnyZodObject, z } from "zod";
55
import queryString from "query-string";
66
import Link from "next/link";
77

@@ -16,6 +16,11 @@ export type RouteInfo<
1616
search: Search;
1717
description?: string;
1818
};
19+
export type BaseRouteInfo = Omit<
20+
RouteInfo<AnyZodObject, AnyZodObject>,
21+
"search"
22+
> &
23+
Partial<Pick<RouteInfo<AnyZodObject, AnyZodObject>, "search">>;
1924

2025
export type GetInfo<Result extends z.ZodSchema> = {
2126
result: Result;
@@ -101,13 +106,12 @@ type GetRouteBuilder<
101106
type DeleteRouteBuilder<
102107
Params extends z.ZodSchema,
103108
Search extends z.ZodSchema
104-
> = CoreRouteElements<Params, z.ZodSchema> & {
105-
(
109+
> = CoreRouteElements<Params, z.ZodSchema> &
110+
((
106111
p?: z.input<Params>,
107112
search?: z.input<Search>,
108113
options?: FetchOptions
109-
): Promise<void>;
110-
};
114+
) => Promise<void>);
111115

112116
export type RouteBuilder<
113117
Params extends z.ZodSchema,
@@ -204,7 +208,7 @@ function createRouteBuilder<
204208

205209
const baseUrl = fn(checkedParams);
206210
const searchString = search && queryString.stringify(search);
207-
return [baseUrl, searchString ? `?${searchString}` : ""].join("");
211+
return ["/", baseUrl, searchString ? `?${searchString}` : ""].join("");
208212
};
209213
}
210214

assets/shared/info.ts.template

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from "zod";
2+
import type { BaseRouteInfo } from "@/routes/makeRoute";
23

34
export const Route = {
45
name: "{{{name}}}",
@@ -7,7 +8,7 @@ export const Route = {
78
{{{this}}},
89
{{/each}}
910
})
10-
};
11+
} satisfies BaseRouteInfo;
1112

1213
{{#each verbs}}
1314
export const {{{this.verb}}} = {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Since the ".env" file is gitignored, you can use the ".env.example" file to
2+
# build a new ".env" file when you clone the repo. Keep this file up-to-date
3+
# when you add new variables to `.env`.
4+
5+
# This file will be committed to version control, so make sure not to have any
6+
# secrets in it. If you are cloning this repo, create a copy of this file named
7+
# ".env" and populate it with your secrets.
8+
9+
# When adding additional environment variables, the schema in "/src/env.js"
10+
# should be updated accordingly.
11+
12+
# Example:
13+
# SERVERVAR="foo"
14+
# NEXT_PUBLIC_CLIENTVAR="bar"

0 commit comments

Comments
 (0)