Skip to content

Instantly share code, notes, and snippets.

@tomfa
Last active May 20, 2021 21:57
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tomfa/fcb8838881080788e139cb2fc2364fcd to your computer and use it in GitHub Desktop.
Save tomfa/fcb8838881080788e139cb2fc2364fcd to your computer and use it in GitHub Desktop.
NextJS JSON state in URL query param
import { parseUrlState } from './utils.query';
import useQueryString from './useQueryString'
type CatInfo = { name: string, age: number };
const defaultCatInfo = { name: 'Robert Paulson', age: 1 };
export const Component = () => {
const [cat, setCat] = useQueryString<CatInfo>({
key: 'cat',
defaultValue: defaultCatInfo,
parser: parseUrlState,
toString: catToString,
});
return <h1>His name is <strong>{cat.name}</strong></h1>
}
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/router';
import { getQueryStringValue, updateQueryString } from './utils.query';
interface Props<T> {
key: string;
defaultValue: T;
parser: (val: string | string[]) => T;
toString: (val: T) => string;
pushUrls?: boolean;
}
function useQueryString<T>({
key,
defaultValue,
parser,
toString,
pushUrls = false,
}: Props<T>): [T, React.Dispatch<React.SetStateAction<T>>] {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [value, setValue] = useState<T>(() => {
const queryParamValue = getQueryStringValue(router, key);
if (queryParamValue) {
return parser(queryParamValue) || defaultValue;
}
return defaultValue;
});
const onSetValue = useCallback(
async (newValue: T | ((existing: T) => T)) => {
setLoading(true);
if (isFunction(newValue)) {
setValue((existing) => {
const updatedValue = newValue(existing);
updateQueryString({
router,
key,
value: toString(updatedValue),
push: pushUrls,
}).then(() => setLoading(false));
setLoading(false);
return updatedValue;
});
} else {
setValue(newValue);
updateQueryString({ router, key, value: toString(newValue), push: pushUrls }).then(() =>
setLoading(false),
);
}
},
[router, key, toString, pushUrls],
);
const queryParamValue = router.query[key];
const queryParamMatches = useMemo(() => {
if (!router.isReady) {
return false;
}
const valueIsUndefinedAsQuery = value === undefined || value === defaultValue;
const expectedQueryValue = valueIsUndefinedAsQuery ? undefined : toString(value);
return queryParamValue === expectedQueryValue;
}, [value, toString, queryParamValue, defaultValue, router.isReady]);
// Update value on url change
useEffect(() => {
if (!router.isReady || queryParamMatches || loading) {
return;
}
setValue(parser(queryParamValue) || defaultValue);
}, [setValue, queryParamMatches, defaultValue, queryParamValue, parser, router.isReady, loading]);
return [value, onSetValue];
}
// eslint-disable-next-line @typescript-eslint/ban-types
const isFunction = (obj: unknown): obj is Function => {
return typeof obj === 'function';
};
export default useQueryString;
export const b64Decode = (base64: string): string => {
return Buffer.from(base64, 'base64').toString('utf-8');
};
export const b64encode = (data: string): string => {
return Buffer.from(data, 'utf-8').toString('base64');
};
export function isValidUrlSafeBase64(value: any): boolean {
if (typeof value !== 'string') {
return false;
}
return encodeUrlSafeBase64(decodeUrlSafeBase64(value)) === value;
}
export function decodeUrlSafeBase64(value: string): string {
const base64Value = value.replace(/-/g, '/').replace(/_/g, '+');
return b64Decode(base64Value);
}
export function encodeUrlSafeBase64(value: string): string {
const base64Value = b64encode(value);
return base64Value.replace(/\//g, '-').replace(/\+/g, '_');
}
import qs from 'query-string';
import { NextRouter } from 'next/router';
import { encodeUrlSafeBase64, decodeUrlSafeBase64 } from 'utils.encoding';
export const getQueryStringValue = (router: NextRouter, key: string) => {
if (router.isReady) {
return router.query[key];
}
if (typeof window !== 'undefined') {
const values = qs.parse(window.location.search);
return values[key];
}
// eslint-disable-next-line no-console
console.log(`Unable to get query param ${key}`);
};
type UpdateQueryStringArgs = {
router: NextRouter;
key: string;
value: string;
push?: boolean;
};
export const updateQueryString = async ({
router,
key,
value,
push = true,
}: UpdateQueryStringArgs) => {
if (push) {
await router.push({ query: { ...router.query, [key]: value } }, undefined, {
shallow: true,
});
} else {
await router.replace({ query: { ...router.query, [key]: value } }, undefined, {
shallow: true,
});
}
};
export function parseUrlState<T = string>(
stateString: string | string[] | undefined,
): T | undefined {
if (!stateString) {
return undefined;
}
try {
return JSON.parse(decodeUrlSafeBase64(String(stateString))) as T;
} catch (err) {
throw new Error(`Unable to parse url state string ${statestring}`);
}
}
export const dataToUrlString = (state: Record<string, unknown>): string => {
try {
return encodeUrlSafeBase64(JSON.stringify(state));
} catch (err) {
throw new Error(`Unable to stringify ${state}`);
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment