A typesafe hook for managing URL state in NextJS

In this post I’ll share a copy/pasteable snippet for a typesafe React hook that allows you to manage state in querystring params.

If you want to skip to the code, click here.

Background

My goal was to create a performant and typesafe hook that can easily replace your useState usage when you want to store state in querystring params.

I found some hooks for querystring state management online, but they weren’t typesafe and had performance issues that caused input lag. (more on that below)

This hook is specifically built for NextJS, but you could easily adapt it to another framework by swapping out Next’s router for another one (e.g. React Router, etc).

Why store state in querystring params?

Storing state in querystring params is great for creating sharable links and having more useful entries in the browser’s history.

There’s quite a bit of writing online about this, so I’ll just leave it at that for now.

How to use the hook

Let’s take a look at how you would use the hook, and then we can talk about how it works.

Below I’ll show a few working examples along with the code for each one.

Usage: Input Example

Here’s an example with a text input field:

1function FavoritePlaceExample() {
2 const key = 'favorite-place';
3 const [favoritePlace, setFavoritePlace] = useQuerystringState({
4 key,
5 defaultValue: '',
6 });
7 
8 return (
9 <div className="flex flex-col gap-2">
10 <label htmlFor={key}>Favorite place:</label>
11 <input
12 id={key}
13 className="border border-slate-300 bg-slate-50 rounded-md p-1"
14 value={favoritePlace}
15 onChange={(e) => setFavoritePlace(e.target.value)}
16 />
17 </div>
18 );
19}

Usage: Radio Example

Here's an example where we can keep track of which radio option is selected.

Just as with useState, you can use typescript features like as const to restrict the possible state values to just a select set of strings. This is optional, but it does add an extra layer of type safety and is how I recommend using this hook in this kind of scenario.

Note that if the default option is selected, it will not be included in the querystring params. This is to keep the URL clean, but you can override that by setting the verboseDefaultParam option to true.

Favorite birthday cake:
1function CakeExample() {
2 const options = ['chocolate', 'vanilla', 'vegan pumpkin pie'] as const;
3 type Option = typeof options[number];
4 const key = 'cake';
5 const [selectedOption, setSelectedOption] = useQuerystringState<Option>({
6 key,
7 defaultValue: options[0],
8 });
9 
10 return (
11 <fieldset>
12 <legend>Favorite birthday cake:</legend>
13 {options.map((option) => (
14 <div key={option}>
15 <input
16 type="radio"
17 value={option}
18 checked={selectedOption === option}
19 id={`${key}-${option}`}
20 onChange={(e) => {
21 // call it just like setState
22 setSelectedOption(e.target.value as Option);
23 }}
24 />
25 <label
26 htmlFor={`${key}-${option}`}
27 className="pl-2"
28 >
29 {option}
30 </label>
31 </div>
32 ))}
33 </fieldset>
34 );
35}

Usage: Checkbox List Example

Below is an example where we can keep track of which dietary restrictions are selected from a list.

In this case, the state variable we’re tracking is an array of strings.

In this example, I’ve set a default value of ['dairy-free', 'likes pumpkin pie'], so the default value will be included in the querystring params.

Note that the hook is robust enough to handle the case where the user deselects all of the options and the default value is not an empty array. If you want to customize the URL aesthetics of that scenario, you can pass in a custom serialize and deserialize functions.

Checkbox list example

1function CheckboxListExample() {
2 const options = ['gluten-free', 'dairy-free', 'vegan', 'likes pumpkin pie'] as const;
3 type Option = typeof options[number];
4 const key = 'dietary-restrictions';
5 const [dietaryRestrictions, setDietaryRestrictions] = useQuerystringState<Option[]>(
6 {
7 key,
8 defaultValue: ['dairy-free', 'likes pumpkin pie'],
9 }
10 );
11 return (
12 <div>
13 <p className="mt-0 mb-0">Checkbox list example</p>
14 {options.map((option) => (
15 <div key={option}>
16 <input
17 type="checkbox"
18 id={`${key}-${option}`}
19 value={option}
20 checked={dietaryRestrictions.includes(option)}
21 onChange={(e) => {
22 // use it just like you would with a normal state variable
23 setDietaryRestrictions(prev => {
24 return prev.includes(option) ?
25 prev.filter(o => o !== option) :
26 [...prev, option];
27 })
28 }}
29 />
30 <label
31 htmlFor={`${key}-${option}`}
32 className="pl-2"
33 >
34 {option}
35 </label>
36 </div>
37 ))}
38 </div>
39 );
40}

Usage: Object Example

Below is an example where the state variable is an object.

These two values in the “pie order form” are stored in an object, and that object is stored in the querystring params.

Note that you can hold down the up/down arrow keys on your keyboard to rapidly change the number of pies without input lag.

Pie order form:
1function ObjectExample() {
2 const objectKeys = [
3 'name',
4 'number-of-pies',
5 ] as const;
6 type ObjectKey = typeof objectKeys[number];
7 const key = 'order-info';
8 const [orderInfo, setOrderInfo] = useQuerystringState<Record<ObjectKey, string | number>>({
9 key,
10 defaultValue: {
11 'name': '',
12 'number-of-pies': 0,
13 }
14 });
15 
16 return (
17 <div className="flex flex-col gap-2 w-fit">
18 {objectKeys.map((key) => (
19 <div
20 className="flex justify-between"
21 key={key}
22 >
23 <label>{key.replaceAll('-', ' ')}</label>
24 <input
25 // please excuse this kind of messy pattern
26 type={key === 'number-of-pies' ? 'number' : 'text'}
27 id={`${key}-${orderInfo[key]}`}
28 value={orderInfo[key]}
29 className="ml-4"
30 onChange={(e) => {
31 // use it just like you would with a normal state variable
32 setOrderInfo(prev => {
33 return {
34 ...prev,
35 [key]: e.target.value,
36 };
37 })
38 }}
39 />
40 </div>
41 ))}
42 </div>
43 );
44}

Okay, now that we’ve seen how to use the hook, let’s talk about a few of its internals.

How to update querystring params in NextJS

We can use the next/navigation primitives to interact with querystring params:

usePathname gives us the current path.

useSearchParams returns a read-only version of the browser’s URLSearchParams.

router.replace replaces the full URL value without adding a new entry to the browser’s history stack.

That last one is really important. Polluting the history stack is super annoying for users.

We can combine the three like this:

1const pathname = usePathname();
2const router = useRouter();
3const searchParams = useSearchParams();
4 
5// create a version of the search params that we can update
6const updatedSearchParams = new URLSearchParams(searchParams.toString());
7updatedSearchParams.set('myKey', 'myValue'); // set the new value
8 
9const queryString = updatedSearchParams.toString();
10const newUrl = `${pathname}?${queryString}`;
11router.replace(newUrl); // update the URL

By updating the querystring params in this way, we’re able to write application state into the URL.

An internal state variable synced to the URL

Just like useState, this hook gives you a state variable and an updater function.

The core logic of the hook is to keep that state variable in sync with the URL on your behalf.

Avoiding infinite loops

To do that syncing, the hook reads the state of the URL and compares it to the state variable.

If they are different, it updates the URL.

If they are the same, it does nothing.

The tripping hazard to avoid here is that JavaScript only considers two objects to be equal if they reference the exact same object in memory. (This is why both of these expressions evaluate to true: {} !== {} and [] !== [].)

This could lead to infinite loops if we were to compare the state and derived state directly.

So instead, we compare the serialized versions.

Debouncing for performance

There are a few hooks like this one floating around online, but they don’t debounce the updates to the URL.

This results in input lag, since writing to the URL on every keystroke is expensive.

The hook has a default debounce time of 300ms, which you can override with the debounceMs prop.

Serialization / Deserialization

Serialization is intentionally kept simple, but you can customize it with the serialize and deserialize props.

For example, you might want to customize the aesthetics of the URL (if JSON.stringify isn’t your vibe). Or maybe you encounter some edge-case where the default behavior doesn’t work well.

Of course, with deserialization you’re dealing with outside data, and there’s the potential for errors. Sensible error-handling is provided, but you can customize it with the onDeserializeError prop.

Possible gotchas

The key thing to consider with shareable links is that someone could visit a URL that was created a long time ago.

This means that changing your default values could break links created with older default values. Using the verboseDefaultParam option can help with that — even if it minorly clutters the URL.

Or — for that matter — the data in the URL might no longer be valid if your application logic has changed.

In the future, I might add a validateDeserializedValue option that could help with that. In the meantime, you can always customize the deserialization with the deserialize option.

Finally, you could footgun yourself by defining bad serialize or deserialize functions. For example, if you choose to serialize all values to the string "foobar", then at deserialization time you’ll have no way of knowing what the original value was. (A nerdier way to say this is that there must be a bijection between the serialized and deserialized values.)

(And, as always, you should avoid storing sensitive information in the querystring.)

Conclusion

I hope you find this hook as useful as I do.

If you have any questions or feedback — or you just want to nerd out — please email me at [my name] @ [this domain].

The code:

(This component for displaying code is a work in progress, and there’s no copy/paste button yet - thanks for bearing with me!)

Note that you’ll need to install use-debounce to use this hook.

1npm i use-debounce --save
1// useQuerystringState.tsx
2'use client';
3 
4import { usePathname, useRouter, useSearchParams } from "next/navigation";
5import { Dispatch, SetStateAction, useCallback, useEffect, useState, useMemo } from "react";
6import { useDebouncedCallback } from 'use-debounce';
7 
8const arrayHandlers = {
9 serialize: <T extends unknown[]>(arr: T) => JSON.stringify(arr.sort()),
10 deserialize: <T extends unknown[]>(str: string): T => JSON.parse(str)
11};
12 
13type SerializeFunction<T> = (value: T) => string;
14type DeserializeFunction<T> = (value: string, defaultValue: T, onError?: (error: Error, value: string) => T) => T;
15type UseQuerystringStateProps<T> = {
16 key: string,
17 defaultValue: T,
18 verboseDefaultParam?: boolean,
19 serialize?: SerializeFunction<T>,
20 deserialize?: DeserializeFunction<T>,
21 debounceMs?: number,
22 onError?: (error: Error, value: string) => T,
23};
24 
25function defaultSerialize<T>(value: T): string {
26 if (typeof value === 'string') {
27 return value;
28 }
29 
30 if (Array.isArray(value)) {
31 return arrayHandlers.serialize(value);
32 }
33 
34 return JSON.stringify(value);
35}
36 
37function defaultDeserialize<T>(value: string, defaultValue: T, onError?: (error: Error, value: string) => T): T {
38 if (typeof defaultValue === 'string') {
39 return value as T;
40 }
41 
42 if (Array.isArray(defaultValue)) {
43 return arrayHandlers.deserialize(value) as T;
44 }
45 
46 try {
47 const result = JSON.parse(value);
48 console.log('Parsed value:', result);
49 return result;
50 } catch (e) {
51 const warningPrefix = `Failed to parse query parameter.`;
52 const warningSuffix = `\nQuery parameter value was:\n${value}`;
53 if (onError) {
54 console.error(warningPrefix + ' Falling back to onError handler.' + warningSuffix);
55 return onError(e as Error, value);
56 }
57 console.error(warningPrefix + warningSuffix);
58 return defaultValue;
59 }
60}
61 
62export function useQuerystringState<T>({
63 key,
64 defaultValue,
65 verboseDefaultParam = false,
66 serialize = defaultSerialize,
67 deserialize = defaultDeserialize,
68 debounceMs = 300,
69 onError,
70}: UseQuerystringStateProps<T>): [T, Dispatch<SetStateAction<T>>] {
71 const pathname = usePathname();
72 const router = useRouter();
73 
74 const searchParams = useSearchParams();
75 const currentUrlParam = searchParams.get(key);
76 const derivedStateValue = useMemo(() => {
77 if (currentUrlParam === null) {
78 return defaultValue;
79 }
80 return deserialize ? deserialize(currentUrlParam, defaultValue, onError) : defaultDeserialize(currentUrlParam, defaultValue, onError);
81 }, [currentUrlParam, defaultValue, deserialize]);
82 
83 const [state, setState] = useState<T>(derivedStateValue);
84 
85 const createQueryString = useCallback(
86 (name: string, newState: T) => {
87 const params = new URLSearchParams(searchParams.toString());
88 console.log('Got params:', params);
89 console.log('Got newState:', newState);
90 
91 if (newState === null || newState === undefined) {
92 params.delete(name);
93 return params.toString();
94 }
95 
96 const serializedNew = serialize(newState);
97 const serializedDefault = serialize(defaultValue);
98 if (serializedNew === serializedDefault && !verboseDefaultParam) {
99 params.delete(name);
100 return params.toString();
101 }
102 
103 if (serializedNew) {
104 params.set(name, serializedNew);
105 } else {
106 params.delete(name);
107 }
108 const result = params.toString();
109 if (result.length > 2000) {
110 console.warn('Querystring exceeds recommended length');
111 }
112 return result;
113 },
114 [searchParams, defaultValue]
115 );
116 
117 // debounce to avoid unnecessary url updates
118 const updateUrl = useDebouncedCallback(async () => {
119 const serializedState = serialize(state);
120 const serializedDerived = serialize(derivedStateValue);
121 
122 if (serializedState !== serializedDerived) {
123 const queryString = createQueryString(key, state);
124 const url = `${pathname}?${queryString}`;
125 try {
126 await router.replace(url, { scroll: false });
127 } catch (error) {
128 // TODO: consider further error handling here
129 console.error('Failed to update URL:', error);
130 }
131 }
132 }, debounceMs);
133 
134 useEffect(() => {
135 updateUrl();
136 }, [state, updateUrl]);
137 
138 return [state, setState];
139}