@domonda/query-params
Version:
Useful but simple query params manipulator for React.
94 lines (93 loc) • 4.22 kB
JavaScript
/**
*
* useQueryParams
*
*/
import { useRef, useState, useLayoutEffect, useContext, useCallback } from 'react';
import { parseQueryParams, defaultQueryParams, stringify } from './queryParams';
import { QueryParamsContext } from './QueryParamsContext';
import { deepEqual } from 'fast-equals';
function defaultSelector(params) {
return params;
}
/**
* Parses the current URL query string following the `model`.
* Updating the params on every location change.
*/
export function useQueryParams(model, props = {}) {
const history = useContext(QueryParamsContext);
if (!history) {
throw new Error('@domonda/query-params history not defined! Consider using `QueryParamsProvider` with the current `history` object.');
}
const { once, onPathname, disableReplace, selector = defaultSelector } = props;
const forceUpdate = useForceUpdate();
// use a refs to avoid unnecessary effect calls
const onPathnameRef = useRef(onPathname);
if (onPathnameRef.current !== onPathname) {
onPathnameRef.current = onPathname;
}
const queryParamsRef = useRef(parseQueryParams(history.location.search, model));
const selectedQueryParamsRef = useRef(selector(queryParamsRef.current));
const replacingRef = useRef(false); // prevents triggering the listener recursively when replacing the URL
const updateQueryParams = useCallback((location) => {
if (replacingRef.current ||
(onPathnameRef.current && onPathnameRef.current !== location.pathname)) {
return;
}
const nextQueryParms = parseQueryParams(location.search, model);
if (!deepEqual(queryParamsRef.current, nextQueryParms)) {
queryParamsRef.current = nextQueryParms;
const selectedNextQueryParams = selector(nextQueryParms);
if (
// @ts-expect-error: because the default selector just passes the T back
selectedNextQueryParams === nextQueryParms || // <- optimization to avoid double deep equal check when defaultSelector is used
!deepEqual(selectedQueryParamsRef.current, selectedNextQueryParams)) {
selectedQueryParamsRef.current = selectedNextQueryParams;
forceUpdate();
}
}
if (!disableReplace) {
const actualQueryString = stringify(queryParamsRef.current, { prependQuestionMark: true });
if (actualQueryString !== history.location.search) {
replacingRef.current = true;
history.replace({
search: actualQueryString,
});
replacingRef.current = false;
}
}
}, [selector, model, disableReplace, onPathnameRef.current]);
useLayoutEffect(() => {
// guarantee history location consistency. sometimes
// history gets updated before the effect is even
// called, this results in stale location state.
// to avoid having such states, we compare the location
// on every effect call and update local state
updateQueryParams(history.location);
if (!once) {
const unlisten = history.listen(updateQueryParams);
return unlisten;
}
}, [updateQueryParams, once]);
return [
selectedQueryParamsRef.current,
useCallback((paramsOrUpdater) => {
const nextParams = Object.assign(Object.assign({}, defaultQueryParams(model)), (paramsOrUpdater instanceof Function
? paramsOrUpdater(queryParamsRef.current)
: paramsOrUpdater));
if (!deepEqual(queryParamsRef.current, nextParams)) {
history.push({
// if we provided the onPathname, then updating the values should push to the route
pathname: onPathnameRef.current ? onPathnameRef.current : history.location.pathname,
search: stringify(nextParams),
});
}
}, []),
];
}
// Force updates the hook.
function useForceUpdate() {
const [, setCounter] = useState(0);
const forceUpdate = useCallback(() => setCounter((counter) => counter + 1), []);
return forceUpdate;
}