Skip to content

Instantly share code, notes, and snippets.

@kelleyvanevert
Created May 22, 2021 10:43
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 kelleyvanevert/9d687b3d6a81d7605d04d55b438d1ed8 to your computer and use it in GitHub Desktop.
Save kelleyvanevert/9d687b3d6a81d7605d04d55b438d1ed8 to your computer and use it in GitHub Desktop.
Custom implementation of useQueryParams

This is a custom implementation of use-query-params, which has served me quite well for about two years, but was getting more and more comprehensive w/ features & a level of abstraction I didn't really need, while also somehow messing up my build process. (The latter is mostly my own fault though, due to my setup w/ SSR, react-ssr-prepass, and trying to keep most of the server code within the webpack output instead of using a generous nodeExternals setting because of the way our CD setup works currently.. But, nonetheless, use-query-params seemed to be a hassle, haha.)

  • No duplicate query params, just a single one. If you want an array, use e.g. StringArrayParam
  • No null vs undefined, or "existent w/o value" vs "inexistent", difference. The query param is either there, or isn't there, and what value is takes depends on the type config.
import { createContext, useMemo, useRef, useContext } from "react";
import { useRefCallback } from "./useRefCallback";
export type UrlUpdateType = "pushIn" | "replaceIn";
export type QueryParamConfig<T> = {
encode(value?: undefined | T): undefined | string;
decode(str?: undefined | string): undefined | T;
};
export type QueryParamConfigMap<D> = {
[K in keyof D]: QueryParamConfig<D[K]>;
};
export type HistoryLike = {
push: (newLocation: LocationLike) => void;
replace: (newLocation: LocationLike) => void;
};
export type LocationLike = {
pathname: string;
search: string;
};
export type HistoryLocationLike = {
history: HistoryLike;
location: LocationLike;
};
export const QueryParamContext = createContext<HistoryLocationLike>({
history: {
push() {
throw new Error("First wrap the app with a <QueryParamContext.Provider>");
},
replace() {
throw new Error("First wrap the app with a <QueryParamContext.Provider>");
},
},
get location() {
return window?.location;
},
});
export const BooleanParam: QueryParamConfig<boolean> = {
encode(value) {
return value ? "" : undefined;
},
decode(str) {
return typeof str === "string";
},
};
export const StringParam: QueryParamConfig<string> = {
encode(value) {
return value;
},
decode(str) {
return str;
},
};
export const NumberParam: QueryParamConfig<number> = {
encode(value) {
return value === undefined ? undefined : String(value);
},
decode(str) {
return str === undefined ? undefined : Number(str);
},
};
export function FixedNumberParam(
fractionDigits: number = 5
): QueryParamConfig<number> {
return {
encode(num) {
return num?.toFixed(fractionDigits);
},
decode(str) {
return str === undefined ? undefined : Number(str);
},
};
}
export const IntParam: QueryParamConfig<number> = {
encode(value) {
return value === undefined ? undefined : String(value);
},
decode(str) {
if (!str) return;
if (str.match(/^[0-9]+$/)) {
return Number(str);
}
},
};
export function EnumParam<K extends string>(values: K[]): QueryParamConfig<K> {
return {
encode(value) {
return value;
},
decode(value) {
if (values.includes(value as any)) {
return value as any;
}
},
};
}
export const GeoPointParam: QueryParamConfig<[number, number]> = {
encode(p) {
return p ? [p[0].toFixed(5), p[1].toFixed(5)].join("_") : undefined;
},
decode(value) {
if (!value) return;
const [_lng, _lat] = String(value).split("_");
if (!_lng || !_lat) {
return;
}
const lat = Number(_lat);
const lng = Number(_lng);
if (isNaN(lng) || isNaN(lat)) {
return;
}
return [lng, lat];
},
};
export function StringArrayParam(separator = "_"): QueryParamConfig<string[]> {
return {
encode(arr) {
if (!arr || arr.length === 0) {
return undefined;
}
return arr.join(separator);
},
decode(value) {
if (!value) return [];
return value.split(separator);
},
};
}
export const UnderscoreSepStringArrayParam = StringArrayParam("_");
export type UseQueryParamResult<T> = [
undefined | T,
(value?: undefined | T, updateType?: UrlUpdateType) => void,
(value?: undefined | T) => string
];
export function useQueryParam<T>(
name: string,
config: QueryParamConfig<T>
): UseQueryParamResult<T> {
const [_data, _setData, _updateSearch] = useQueryParams({
[name]: config,
});
const value = _data[name];
const updateSearch = useRefCallback((value?: undefined | T) => {
return _updateSearch({ [name]: value });
});
const setValue = useRefCallback(
(value?: undefined | T, updateType?: UrlUpdateType) => {
_setData({ [name]: value }, updateType);
}
);
return [value, setValue, updateSearch];
}
export type UseQueryParamsResult<D> = [
{
[K in keyof D]: undefined | D[K];
},
(
updates: {
[K in keyof D]?: undefined | D[K];
},
updateType?: UrlUpdateType
) => void,
(
updates: {
[K in keyof D]?: undefined | D[K];
}
) => string
];
export function useQueryParams<D>(
configMap: QueryParamConfigMap<D>
): UseQueryParamsResult<D> {
const { history, location } = useContext(QueryParamContext);
const configMapRef = useRef(configMap);
const data: {
[K in keyof D]: undefined | D[K];
} = useMemo(() => {
const data: any = {};
const keys = Object.keys(configMapRef.current);
for (const key of keys) {
const config = (configMapRef.current as any)[
key
] as QueryParamConfig<any>;
data[key] = config.decode(find(location.search, key));
}
return data;
}, [location.search]);
const updateSearch = useRefCallback(
(
updates: {
[K in keyof D]?: undefined | D[K];
}
) => {
let search = location.search;
const updateKeys = Object.keys(updates);
for (const key of updateKeys) {
const config = (configMapRef.current as any)[
key
] as QueryParamConfig<any>;
search = update(search, key, config.encode((updates as any)[key]));
}
return search;
}
);
const setData = useRefCallback(
(
updates: {
[K in keyof D]?: undefined | D[K];
},
updateType?: UrlUpdateType
) => {
const newLocation = {
pathname: location.pathname,
search: updateSearch(updates),
};
if (updateType === "replaceIn") {
history.replace(newLocation);
} else {
history.push(newLocation);
}
}
);
return [data, setData, updateSearch];
}
/**
* Removes or updates the query param value within the search string.
* Pass `undefined` to remove, an empty string to set w/o value,
* and any other string to set the value to that value. Will
* automatically uri-encode the value. Will also remove duplicate
* query params in the process.
*/
function update(
search: string,
name: string,
value?: string // undefined means: remove
) {
if (search[0] === "?") {
search = search.slice(1);
}
const pieces = search.split("&").filter((p) => {
return p !== name && !p.startsWith(name + "=");
});
if (typeof value === "string") {
pieces.push(name + (value ? "=" + encodeURIComponent(value) : ""));
}
search = pieces.join("&");
if (search.length > 0 && search[0] !== "?") {
search = "?" + search;
}
return search;
}
/**
* Returns the uri-decoded single string value of the given query param,
* if any. Returns an empty string if in the search string but
* without associated value.
*/
function find(search: string, name: string): string | undefined {
if (search[0] === "?") {
search = search.slice(1);
}
const piece = search.split("&").find((p) => {
return p === name || p.startsWith(name + "=");
});
if (piece) {
return decodeURIComponent(
piece === name ? "" : piece.slice(name.length + 1)
);
}
}
import { useRef, useCallback } from "react";
export function useRefCallback<A extends any[], T>(
fn: (...args: A) => T
): ((...args: A) => T) & { provided: boolean };
export function useRefCallback<A extends any[], T>(
fn?: (...args: A) => T
): ((...args: A) => undefined | T) & { provided: boolean };
export function useRefCallback<A extends any[], T>(
fn?: (...args: A) => T
): ((...args: A) => undefined | T) & { provided: boolean } {
const _fn = useRef(fn);
_fn.current = fn;
const wrapper = useCallback((...args: A) => {
if (_fn.current) {
return _fn.current(...args);
}
}, []);
// @ts-ignore
wrapper.provided = !!fn;
// @ts-ignore
return wrapper;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment