react-magic-search-params
Version:
#️⃣ A React Hook for advanced, typed management of URL search parameters, providing built-in TypeScript autocomplete.
527 lines (446 loc) • 20.8 kB
text/typescript
import { useSearchParams } from 'react-router-dom'
import { useMemo, useEffect, useRef, useCallback } from 'react'
// Custom hook with advanced techniques to handle search parameters for any pagination
type CommonParams = {
page?: number
page_size?: number
}
/*
Maps all properties of M (mandatory) as required
and all properties of O (optional) as optional.
*/
type MergeParams<M, O> = {
[K in keyof M]: M[K]
} & {
[K in keyof O]?: O[K]
}
/**
* Interface for the configuration object that the hook receives
*/
export interface UseMagicSearchParamsOptions<
M extends Record<string, unknown>,
O extends Record<string, unknown>
> {
mandatory: M
optional?: O
defaultParams?: Partial<MergeParams<M, O>>
forceParams?: Partial<MergeParams<M, O>> // transform all to partial to avoid errors
arraySerialization?: 'csv' | 'repeat' | 'brackets' // technical to serialize arrays in the URL
omitParamsByValues?: Array<'all' | 'default' | 'unknown' | 'none' | 'void '>
}
/**
Generic hook to handle search parameters in the URL
@param mandatory - Mandatory parameters (e.g., page=1, page_size=10, etc.)
@param optional - Optional parameters (e.g., order, search, etc.)
@param defaultParams - Default parameters sent in the URL on initialization
@param forceParams - Parameters forced into the URL regardless of user input
@param omitParamsByValues - Parameters omitted if they have specific values
*/
export const useMagicSearchParams = <
M extends Record<string, unknown> & CommonParams,
O extends Record<string, unknown>,
>({
mandatory = {} as M,
optional = {} as O,
defaultParams = {} as Partial<MergeParams<M, O>>,
arraySerialization = 'csv',
forceParams = {} as {} as Partial<MergeParams<M, O>>,
omitParamsByValues = [] as Array<'all' | 'default' | 'unknown' | 'none' | 'void '>
}: UseMagicSearchParamsOptions<M, O>)=> {
const [searchParams, setSearchParams] = useSearchParams()
// Ref to store subscriptions: { paramName: [callback1, callback2, ...] }
const subscriptionsRef = useRef<Record<string, Array<() => unknown>>>({});
const previousParamsRef = useRef<Record<string, unknown>>({})
const TOTAL_PARAMS_PAGE: MergeParams<M, O> = useMemo(() => {
return { ...mandatory, ...optional };
}, [mandatory, optional]);
const PARAM_ORDER = useMemo(() => {
return Array.from(Object.keys(TOTAL_PARAMS_PAGE))
}, [TOTAL_PARAMS_PAGE])
// we get the keys that are arrays according to TOTAL_PARAMS_PAGE since these require special treatment in the URL due to serialization mode
const ARRAY_KEYS = useMemo(() => {
return Object.keys(TOTAL_PARAMS_PAGE).filter(
(key) => Array.isArray(TOTAL_PARAMS_PAGE[key])
);
}, [TOTAL_PARAMS_PAGE])
const appendArrayValues = (
finallyParams: Record<string, unknown>,
newParams: Record<string, string | string[] | unknown>
): Record<string, unknown> => {
// Note: We cannot modify the object of the final parameters directly, as immutability must be maintained
const updatedParams = { ...finallyParams };
if (ARRAY_KEYS.length === 0) return updatedParams;
ARRAY_KEYS.forEach((key) => {
// We use the current values directly from searchParams (source of truth)
// This avoids depending on finallyParams in which the arrays have been omitted
let currentValues = [];
switch (arraySerialization) {
case 'csv': {
const raw = searchParams.get(key) || '';
// For csv we expect "value1,value2,..." (no prefix)
currentValues = raw.split(',')
.map((v) => v.trim())
.filter(Boolean) as Array<string>
break;
}
case 'repeat': {
// Here we get all ocurrences of key
const urlParams = searchParams.getAll(key) as Array<string>
currentValues = urlParams.length > 0 ? urlParams : []
break;
}
case 'brackets': {
// Build URLSearchParams from current parameters (to ensure no serialized values are taken previously)
const urlParams = searchParams.getAll(`${key}[]`) as Array<string>
currentValues = urlParams.length > 0 ? urlParams : []
break;
}
default: {
// Mode by default works as csv
const raw = searchParams.get(key) ?? '';
currentValues = raw.split(',')
.map((v) => v.trim())
.filter(Boolean);
}
break;
}
// Update array values with new ones
if (newParams[key] !== undefined) {
const incoming = newParams[key];
let combined: string[] = []
if (typeof incoming === 'string') {
// If it is a string, it is toggled (add/remove)
combined = currentValues.includes(incoming)
? currentValues.filter((v) => v !== incoming)
: [...currentValues, incoming];
} else if (Array.isArray(incoming)) {
// if an array is passed, repeated values are merged into a single value
// Note: Set is used to remove duplicates
combined = Array.from(new Set([ ...incoming]));
} else {
combined = currentValues;
}
updatedParams[key] = combined
}
});
return updatedParams
};
const transformParamsToURLSearch = (params: Record<string, unknown>): URLSearchParams => {
console.log({PARAMS_RECIBIDOS_TRANSFORM: params})
const newParam: URLSearchParams = new URLSearchParams()
const paramsKeys = Object.keys(params)
for (const key of paramsKeys) {
if (Array.isArray(TOTAL_PARAMS_PAGE[key])) {
const arrayValue = params[key] as unknown[]
console.log({arrayValue})
switch (arraySerialization) {
case 'csv': {
const csvValue = arrayValue.join(',')
// set ensure that the previous value is replaced
newParam.set(key, csvValue)
break
} case 'repeat': {
for (const item of arrayValue) {
// add new value to the key, instead of replacing it
newParam.append(key, item as string)
}
break
} case 'brackets': {
for (const item of arrayValue) {
newParam.append(`${key}[]`, item as string)
}
break
} default: {
const csvValue = arrayValue.join(',')
newParam.set(key, csvValue)
}
}
} else {
newParam.set(key, params[key] as string)
}
}
return newParam
}
// @ts-ignore
const hasForcedParamsValues = ({ paramsForced, compareParams }) => {
// Iterates over the forced parameters and verifies that they exist in the URL and match their values
// Ej: { page: 1, page_size: 10 } === { page: 1, page_size: 10 } => true
const allParamsMatch = Object.entries(paramsForced).every(
([key, value]) => compareParams[key] === value
);
return allParamsMatch;
};
useEffect(() => {
const keysDefaultParams: string[] = Object.keys(defaultParams)
const keysForceParams: string[] = Object.keys(forceParams)
if(keysDefaultParams.length === 0 && keysForceParams.length === 0) return
function handleStartingParams() {
const defaultParamsString = transformParamsToURLSearch(defaultParams).toString()
const paramsUrl = getParams()
const paramsUrlString = transformParamsToURLSearch(paramsUrl).toString()
const forceParamsString = transformParamsToURLSearch(forceParams).toString()
console.log({defaultParamsString})
const isForcedParams: boolean = hasForcedParamsValues({ paramsForced: forceParams, compareParams: paramsUrl })
if (!isForcedParams) {
// In this case, the forced parameters take precedence over the default parameters and the parameters of the current URL (which could have been modified by the user, e.g., page_size=1000)
updateParams({ newParams: {
...defaultParams,
...forceParams
}})
return
}
// In this way it will be validated that the forced parameters keys and values are in the current URL
const isIncludesForcedParams = hasForcedParamsValues({ paramsForced: forceParamsString, compareParams: defaultParams })
if (keysDefaultParams.length > 0 && isIncludesForcedParams) {
if (defaultParamsString === paramsUrlString) return // this means that the URL already has the default parameters
updateParams({ newParams: defaultParams })
}
}
handleStartingParams()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
/**
* Convert a string value to its original type (number, boolean, array) according to TOTAL_PARAMS_PAGE
* @param value - Chain obtained from the URL
* @param key - Key of the parameter
*/
const convertOriginalType = (value: string, key: string) => {
// Given that the parameters of a URL are recieved as strings, they are converted to their original type
if (typeof TOTAL_PARAMS_PAGE[key] === 'number') {
return parseInt(value)
} else if (typeof TOTAL_PARAMS_PAGE[key] === 'boolean') {
return value === 'true'
} else if (Array.isArray(TOTAL_PARAMS_PAGE[key])) {
// The result will be a valid array represented in the URL ej: tags=tag1,tag2,tag3 to ['tag1', 'tag2', 'tag3'], useful to combine the values of the arrays with the new ones
if (arraySerialization === 'csv') {
return searchParams.getAll(key).join('').split(',')
} else if (arraySerialization === 'repeat') {
console.log({SEARCH_PARAMS: searchParams.getAll(key)})
return searchParams.getAll(key)
} else if (arraySerialization === 'brackets') {
return searchParams.getAll(`${key}[]`)
}
}
// Note: dates are not converted as it is better to handle them directly in the component that receives them, using a library like < date-fns >
return value
}
/**
* Gets the current parameters from the URL and converts them to their original type if desired
* @param convert - If true, converts from string to the inferred type (number, boolean, ...)
*/
const getStringUrl = (key: string, paramsUrl: Record<string, unknown>) => {
const isKeyArray = Array.isArray(TOTAL_PARAMS_PAGE[key])
if (isKeyArray) {
if (arraySerialization === 'brackets') {
const arrayUrl = searchParams.getAll(`${key}[]`)
const encodedQueryArray = transformParamsToURLSearch({ [key]: arrayUrl }).toString()
// in this way the array of the URL is decoded to its original form ej: tags[]=tag1&tags[]=tag2&tags[]=tag3
const unencodeQuery = decodeURIComponent(encodedQueryArray)
return unencodeQuery
} else if (arraySerialization === 'csv') {
const arrayValue = searchParams.getAll(key)
const encodedQueryArray = transformParamsToURLSearch({ [key]: arrayValue }).toString()
const unencodeQuery = decodeURIComponent(encodedQueryArray)
return unencodeQuery
}
const arrayValue = searchParams.getAll(key)
const stringResult = transformParamsToURLSearch({ [key]: arrayValue }).toString()
return stringResult
} else {
return paramsUrl[key] as string
}
}
const getParamsObj = (searchParams: URLSearchParams): Record<string, string | string[]> => {
const paramsObj: Record<string, string | string[]> = {};
// @ts-ignore
for (const [key, value] of searchParams.entries()) {
if (key.endsWith('[]')) {
const bareKey = key.replace('[]', '');
if (paramsObj[bareKey]) {
(paramsObj[bareKey] as string[]).push(value);
} else {
paramsObj[bareKey] = [value];
}
} else {
// If the key already exists, it is a repeated parameter
if (paramsObj[key]) {
if (Array.isArray(paramsObj[key])) {
(paramsObj[key] as string[]).push(value);
} else {
paramsObj[key] = [paramsObj[key] as string, value];
}
} else {
paramsObj[key] = value;
}
}
}
return paramsObj;
}
// Optimization: While the parameters are not updated, the current parameters of the URL are not recalculated
const CURRENT_PARAMS_URL: Record<string, unknown> = useMemo(() => {
return arraySerialization === 'brackets' ? getParamsObj(searchParams) : Object.fromEntries(searchParams.entries())
}, [searchParams, arraySerialization])
/**
* Gets the current parameters from the URL and converts them to their original type if desired
* @param convert - If true, converts from string to the inferred type (number, boolean, ...)
* @returns - Returns the current parameters of the URL
*/
const getParams = ({ convert = true } = {}): MergeParams<M, O> => {
// All the paramteres are extracted from the URL and converted into an object
const params = Object.keys(CURRENT_PARAMS_URL).reduce((acc, key) => {
if (Object.prototype.hasOwnProperty.call(TOTAL_PARAMS_PAGE, key)) {
const realKey = arraySerialization === 'brackets' ? key.replace('[]', '') : key
// @ts-ignore
acc[realKey] = convert === true
? convertOriginalType(CURRENT_PARAMS_URL[key] as string, key)
: getStringUrl(key, CURRENT_PARAMS_URL)
}
return acc
}, {})
return params as MergeParams<M, O>
}
type keys = keyof MergeParams<M, O>
// Note: in this way the return of the getParam function is typed dynamically, thus having autocomplete in the IDE (eg: value.split(','))
type TagReturn<T extends boolean> = T extends true ? string[] : string;
/**
* Gets the value of a parameter from the URL and converts it to its original type if desired
* @param key - Key of the parameter
* @param options - Options to convert the value to its original type, default is true
* @returns - Returns the value of the parameter
*/
const getParam = <T extends boolean>(key: keys, options?: { convert: T }): TagReturn<T> => {
const keyStr = String(key)
// @ts-ignore
const value = options?.convert === true ? convertOriginalType(searchParams.get(keyStr), keyStr) : getStringUrl(keyStr, CURRENT_PARAMS_URL)
return value as TagReturn<T>
}
type OptionalParamsFiltered = Partial<O>
const calculateOmittedParameters = (newParams: Record<string, unknown | unknown[]>, keepParams: Record<string, boolean>) => {
// Calculate the ommited parameters, that is, the parameters that have not been sent in the request
const params = getParams()
// hasOw
// Note: it will be necessary to omit the parameters that are arrays because the idea is not to replace them but to add or remove some values
const newParamsWithoutArray = Object.entries(newParams).filter(([key,]) => !Array.isArray(TOTAL_PARAMS_PAGE[key]))
const result = Object.assign({
...params,
...Object.fromEntries(newParamsWithoutArray),
...forceParams // the forced parameters will always be sent and will maintain their value
})
const paramsFiltered: OptionalParamsFiltered = Object.keys(result).reduce((acc, key) => {
// for default no parameters are omitted unless specified in the keepParams object
if (Object.prototype.hasOwnProperty.call(keepParams, key) && keepParams[key] === false) {
return acc
// Note: They array of parameters omitted by values (e.g., ['all', 'default']) are omitted since they are usually a default value that is not desired to be sent
} else if (!!result[key] !== false && !omitParamsByValues.includes(result[key])) {
// @ts-ignore
acc[key] = result[key]
}
return acc
}, {})
return {
...mandatory,
...paramsFiltered
}
}
// @ts-ignore
const sortParameters = (paramsFiltered) => {
// sort the parameters according to the structure so that it persists with each change in the URL, eg: localhost:3000/?page=1&page_size=10
// Note: this visibly improves the user experience
const orderedParams = PARAM_ORDER.reduce((acc, key) => {
if (Object.prototype.hasOwnProperty.call(paramsFiltered, key)) {
// @ts-ignore
acc[key] = paramsFiltered[key]
}
return acc
}, {})
return orderedParams
}
const mandatoryParameters = () => {
// Note: in case there are arrays in the URL, they are converted to their original form ej: tags=['tag1', 'tag2'] otherwise the parameters are extracted without converting to optimize performance
const isNecessaryConvert: boolean = ARRAY_KEYS.length > 0 ? true : false
const totalParametros: Record<string, unknown> = getParams({ convert: isNecessaryConvert })
const paramsUrlFound: Record<string, boolean> = Object.keys(totalParametros).reduce(
(acc, key) => {
if (Object.prototype.hasOwnProperty.call(mandatory, key)) {
// @ts-ignore
acc[key] = totalParametros[key]
}
return acc
},
{}
)
return paramsUrlFound
}
/**
clears the parameters of the URL, keeping the mandatory parameters
* @param keepMandatoryParams - If true, the mandatory parameters are kept in the URL
*/
const clearParams = ({ keepMandatoryParams = true } = {}): void => {
// for default, the mandatory parameters are not cleared since the current pagination would be lost
const paramsTransformed = transformParamsToURLSearch(
{
...mandatory,
...(keepMandatoryParams && {
...mandatoryParameters()
}),
...forceParams
}
)
setSearchParams(paramsTransformed)
}
// transforms the keys to boolean to know which parameters to keep
type KeepParamsTransformedValuesBoolean = Partial<Record<keyof typeof TOTAL_PARAMS_PAGE, boolean>>
type NewParams = Partial<typeof TOTAL_PARAMS_PAGE>
type KeepParams = KeepParamsTransformedValuesBoolean
/**
Merges the new parameters with the current ones, omits the parameters that are not sent and sorts them according to the structure
* @param newParams - New parameters to be sent in the URL
* @param keepParams - Parameters to keep in the URL, default is true
*/
const updateParams = ({ newParams = {} as NewParams, keepParams = {} as KeepParams } = {}) => {
if (
Object.keys(newParams).length === 0 &&
Object.keys(keepParams).length === 0
) {
clearParams()
return
}
// @ts-ignore
const finallyParamters = calculateOmittedParameters(newParams, keepParams)
const convertedArrayValues = appendArrayValues(finallyParamters, newParams)
const paramsSorted = sortParameters(convertedArrayValues)
setSearchParams(transformParamsToURLSearch(paramsSorted))
}
/**
* @param paramName - Name of the parameter to subscribe to
* @param callbacks - Callbacks to be executed when the parameter changes
* @returns - Returns the function to unsubscribe
*/
const onChange = useCallback( (paramName: keys, callbacks: Array<() => void>) => {
const paramNameStr = String(paramName)
// replace the previous callbacks with the new ones so as not to accumulate callbacks
subscriptionsRef.current[paramNameStr] = callbacks;
}, [])
// each time searchParams changes, we notify the subscribers
useEffect(() => {
for (const [key, value] of Object.entries(subscriptionsRef.current)) {
const newValue = CURRENT_PARAMS_URL[key] ?? null
const oldValue = previousParamsRef.current[key] ?? null
if (newValue !== oldValue) {
for (const callback of value) {
callback()
}
}
// once the callback is executed, the previous value is updated to ensure that the next time the value changes, the callback is executed
previousParamsRef.current[key] = newValue
}
}, [CURRENT_PARAMS_URL])
return {
searchParams,
updateParams,
clearParams,
getParams,
getParam,
onChange
}
}