UNPKG

@domonda/query-params

Version:

Useful but simple query params manipulator for React.

94 lines (93 loc) 4.22 kB
/** * * 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; }