Skip to content

Instantly share code, notes, and snippets.

@donaldpipowitch
Last active February 27, 2024 15:42
Show Gist options
  • Save donaldpipowitch/95350eb1db4ac6fbecaff0b3790c712b to your computer and use it in GitHub Desktop.
Save donaldpipowitch/95350eb1db4ac6fbecaff0b3790c712b to your computer and use it in GitHub Desktop.
Type-safe query params in React with a "Hook Builder"

Everyone loves type-safe APIs nowadays, but as far as I can tell query parameters are a blind spot to most people right now.

I create a hook which uses a builder pattern a while ago, which allows you to create type-safe query parameters. It is not very polished and contains some specific use cases, that's why I hesitated to share it. But as I successfully use it for a couple of month already and (afaik) no one came up with something similar so far, I wanted to share it.

How does it look like?

import * as pp from './page-params';

type FavoriteFood = 'pizza' | 'noodles' | 'wraps' | 'hot-dogs';

// imagine those queries control the state for a users overview
function useUsersParams() {
  return pp.usePageParams(
    pp
      .configure()
      .add(pp.start('start'))
      .add(pp.number('pageSize').default(10))
      .add(
        pp
          .boolean('onlyPremium')
          .default(false)
          .filter({ label: 'Only Premium' })
      )
      .add(pp.optionalNumber('age').default(18).filter({ label: 'Age' }))
      .add(pp.param('name').filter({ label: 'Name' }))
      .add(
        pp
          .list('favoriteFood')
          .cast<FavoriteFood>()
          .filter({ label: 'Favorite Food' })
      )
  );
}

const params = useUsersParams();
params.defs; // get defintions, e.g. to access defaults
params.defs.age.default; // === 18
params.value; // get all current values
params.value.age; // number | null
params.filterValues; // get all current filter values
params.filterValues.age; // number | null
params.filterLabels; // get all labels of the filter params
params.filterValues.age; // === 'Age'
params.hasActiveFilters; // returns true, if _any_ filter param is not default
params.resetFilters(); // resets filter params to default
params.set(); // allows you to set any param
params.setFilters(); // allows you to set any filter param

As you can see we had the use case of categorizing params: regular params and params used as filters. This allowed us to call things like resetFilters() which will reset all params which were flagged as filter(), but not the other params. Or: Whenever you run setFilters() the param which was marked as start() will be set to 0 automatically. (Which means: If you filter a collection and you reconfigure your filters, you'll jump back to the start.) I use the labels in several places: the actual filter form, as column headers or in error messages. It was just convenient to configure them in a single place.

If you don't need this, you can also fully ignore it and just don't call filter() after your param definition. Of course you could also just come up with your own categories. E.g. I could imagine running state() behind a param definition, which will mark it as a param which is stored in the History State, instead of the Query String in your URL.

Categories also help to create more sophisticated components. We have an <ActiveFilters/> component in our source code, which maps filter params to different UIs. Thanks to generic types like FilterKeys we can ensure that every filter has its own mapping and if at some point in time a new filter is added TypeScript will remember us that we might missed to add a new mapping.

If you have multiple filters which belong to different collections which can be individually filtered, sorted, paginated, etc. you can also group the params.

import * as pp from './page-params';

type UsersSortBy = 'age' | 'firstName' | 'lastNamee';

type PetsSortBy = 'age' | 'name';

type SortDirection = 'asc' | 'desc';

export function usePageParams() {
  const users = pp.usePageParams(
    pp
      .configure()
      .add(pp.start('start'))
      .add(pp.param('sortBy').default<UsersSortBy>('firstName'))
      .add(pp.param('sortDirection').default<SortDirection>('desc')),
    'users'
  );

  const pets = pp.usePageParams(
    pp
      .configure()
      .add(pp.start('start'))
      .add(pp.param('sortBy').default<PetsSortBy>('name'))
      .add(pp.param('sortDirection').default<SortDirection>('desc')),
    'pets'
  );

  return {
    users,
    pets,
  };
}

You can also use more complex data structures, if needed.

import * as pp from './page-params';

type User = { id: string; name: string };

export function usePageParams() {
  return pp.usePageParams(
    pp
      .configure()
      .add(
        pp
          .list('users')
          .deserialize<User[]>(JSON.parse)
          .serialize(JSON.stringify)
          .filter({ label: 'Users' })
      ),
  );
}

I hope this illustrates the idea behind this API. It's a bit verbose here and there and the implementation is tricky, but it really helped us to reduce a lot of error prone code by adding type-safety and some additional logic like "categories" or grouping.


PS: The builder pattern was needed, because I found no other way to get the same type-safety in TypeScript without it. If you know a better way, please let me know.

import { sortBy, isEqual } from 'lodash';
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { useAmplitude } from 'src/hooks/use-amplitude';
import { useSearchParams } from 'src/hooks/use-search-params';
import { useMemoOne } from 'use-memo-one';
import { getFilterEventData } from '../get-filter-event-data';
import { FilterConfig, InferFilters, InferValues, FilterKeys } from './types';
type Nullable<T> = {
[P in keyof T]: T[P] | null;
};
interface ParamValueDef {
isList: false;
default?: unknown;
optionalDefault?: true;
deserialize?: (value: string | null) => unknown;
serialize?: (value: unknown) => string;
}
interface ParamListDef {
isList: true;
default?: unknown;
optionalDefault?: true;
deserialize?: (value: string[]) => unknown;
serialize?: (value: unknown[]) => string[];
}
interface ParamTypeFilter {
type: 'filter';
filter: FilterConfig;
}
interface ParamTypeStart {
type: 'start';
}
interface ParamTypeDefault {
type?: undefined;
filter: FilterConfig;
}
export type ParamDef = (ParamValueDef | ParamListDef) &
(ParamTypeFilter | ParamTypeStart | ParamTypeDefault);
type Builder<T> = { build: () => T };
type FilterLabels<T> = Record<FilterKeys<T>, string>;
const optionalDefaultValue = '_null_';
export function configure<Defs = {}>(defs = {}) {
return {
// provide a builder defintion to collect strongly
// typed query parameter defintions
add<Def>(defBuilder: Builder<Def>) {
return configure<Defs & Def>({
...defs,
...defBuilder.build(),
});
},
// creates our hook based on the previously collected strongly
// typed query parameter defintions
build() {
return defs as Defs;
},
};
}
export interface PageParams<Defs> {
hasActiveFilters: boolean;
resetFilters: () => void;
filterLabels: FilterLabels<Defs>;
value: InferValues<Defs>;
set: (value: Partial<InferValues<Defs>>) => { hasChanged: boolean };
filterValues: InferFilters<Defs>;
setFilters: (value: Partial<InferFilters<Defs>>) => { hasChanged: boolean };
defs: Defs;
}
export function usePageParams<Defs>(
builder: Builder<Defs>,
group = ''
): PageParams<Defs> {
const searchParams = useSearchParams();
const { push } = useHistory();
const { triggerSurvey } = useAmplitude();
// get parameter defnitions on mount
const defs = useMemoOne(builder.build, []);
// get initial hook data on mount
const { filterLabels, filterDefaults, startDefault } = useMemoOne(
() => collectGeneralHookData(defs as Defs),
[]
);
// whenever search params change we collect all values
const { value, filterValues } = useMemoOne(
() => collectValues(defs as Defs, searchParams, group),
[searchParams, group]
);
// whenever filterFalues change, we check if there are active filters
const hasActiveFilters = useMemoOne(
() => checkActiveFilters(defs as Defs, filterValues),
[filterValues]
);
const set = useCallback(
(values: Nullable<Partial<InferValues<Defs>>>) => {
const updatedSearchParams = updateSearchParams(
defs as Defs,
values,
searchParams,
group
);
const hasChanged = `?${searchParams}` !== updatedSearchParams;
push(updatedSearchParams);
return { hasChanged };
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[searchParams, push, group]
);
const resetFilters = useCallback(() => {
set({
...filterDefaults,
// whenever filters change we change start back to default (if it exists)
...startDefault,
});
triggerSurvey();
}, [set, triggerSurvey, filterDefaults, startDefault]);
const setFilters = useCallback(
(values: Partial<InferFilters<Defs>>) => {
const Filter = getFilterEventData(values, filterLabels);
triggerSurvey();
const result = set({
...(values as Nullable<Partial<InferValues<Defs>>>),
// whenever filters change we change start back to default (if it exists)
...startDefault,
});
return result;
},
[set, triggerSurvey, startDefault, filterLabels]
);
return {
hasActiveFilters,
resetFilters,
filterLabels,
filterValues,
value,
set,
setFilters,
defs: defs as Defs,
};
}
// the returned data will be collected _once_ when the hook is mounted
function collectGeneralHookData<Defs>(defs: Defs) {
let startDefault: Record<string, unknown> | null = null;
const filterDefaults: Record<string, unknown> = {};
const filterLabels: Record<string, string> = {};
Object.keys(defs).forEach((key) => {
const def: ParamDef = (defs as any)[key];
if (def.type === 'start') {
startDefault = { [key]: def.default };
} else if (def.type === 'filter') {
filterLabels[key] = def.filter.label;
if (def.default !== undefined) {
filterDefaults[key] = def.default;
} else if (def.isList) {
filterDefaults[key] = [];
} else {
filterDefaults[key] = null;
}
}
});
return {
filterLabels: filterLabels as FilterLabels<Defs>,
filterDefaults: filterDefaults as Partial<InferValues<Defs>>,
startDefault: startDefault as Partial<InferValues<Defs>> | null,
};
}
// given a search param all values will be collected
function collectValues<Defs>(
defs: Defs,
searchParams: URLSearchParams,
group: string
) {
const value = {} as any;
const filterValues = {} as any;
Object.keys(defs).forEach((key) => {
const def: ParamDef = (defs as any)[key];
if (def.isList) {
const paramValues = searchParams.getAll(group + key);
if (paramValues.length === 0 && def.default !== undefined) {
value[key] = def.default;
} else if (
paramValues[0] === optionalDefaultValue &&
def.optionalDefault
) {
value[key] = [];
} else {
value[key] = def.deserialize
? def.deserialize(paramValues)
: paramValues;
}
} else {
const paramValue = searchParams.get(group + key);
if (paramValue === null && def.default !== undefined) {
value[key] = def.default;
} else if (paramValue === optionalDefaultValue && def.optionalDefault) {
value[key] = null;
} else {
value[key] = def.deserialize ? def.deserialize(paramValue) : paramValue;
}
}
if (def.type === 'filter') filterValues[key] = value[key];
});
return {
value: value as InferValues<Defs>,
filterValues: filterValues as InferFilters<Defs>,
};
}
export function isActiveFilter<Defs>(
key: FilterKeys<Defs>,
defs: Defs,
filterValues: InferFilters<Defs>
) {
const def: ParamDef = (defs as any)[key];
const val: unknown = (filterValues as any)[key];
if (Array.isArray(val)) {
if (def.default !== undefined) {
if (val.length === 0) {
return true;
} else {
return !isEqual(sortBy(def.default as unknown[]), sortBy(val));
}
} else {
return Boolean(val.length);
}
}
if (typeof val === 'boolean') return val;
if (def.default === undefined) return val != null;
return val !== def.default;
}
function checkActiveFilters<Defs>(
defs: Defs,
filterValues: InferFilters<Defs>
) {
return Object.keys(filterValues).some((key) =>
isActiveFilter(key as FilterKeys<Defs>, defs, filterValues)
);
}
function updateSearchParams<Defs>(
defs: Defs,
values: Nullable<Partial<InferValues<Defs>>>,
searchParams: URLSearchParams,
group: string
) {
const updatedParams = new URLSearchParams(searchParams);
Object.keys(values).forEach((key) => {
const def: ParamDef = (defs as any)[key];
const checkValue = (values as any)[key];
const value = def.serialize?.(checkValue) ?? checkValue;
if (value === undefined) return;
if (Array.isArray(value)) {
if (value.length === 0 && def.optionalDefault) {
updatedParams.delete(group + key);
updatedParams.append(group + key, optionalDefaultValue);
} else {
updatedParams.delete(group + key);
value.forEach((item) => updatedParams.append(group + key, item));
}
} else if (value === null && def.optionalDefault) {
updatedParams.set(group + key, optionalDefaultValue);
} else if (value === null || value === '' || value === def.default) {
updatedParams.delete(group + key);
} else {
updatedParams.set(group + key, String(value));
}
});
return `?${updatedParams}`;
}
export * from './types';
export * from './hook';
export * from './list';
export * from './param';
import { FilterConfig } from './types';
export function list<Value, Key extends string, Def = { isList: true }>(
key: Key,
def = { isList: true } as {}
) {
return {
cast<Value>() {
return list<Value, Key, Def & { _cast: Value }>(key, {
...def,
_cast: undefined as any,
});
},
filter(filter: FilterConfig) {
return list<Value, Key, Def & { type: 'filter'; filter: FilterConfig }>(
key,
{
...def,
type: 'filter',
filter,
}
);
},
default<DefaultValue extends Value>(value: DefaultValue) {
return list<DefaultValue, Key, Def & { default: DefaultValue }>(key, {
...def,
default: value,
});
},
optionalDefault<DefaultValue extends Value>(value: DefaultValue) {
return list<
DefaultValue,
Key,
Def & { default: DefaultValue; optionalDefault: true }
>(key, {
...def,
default: value,
optionalDefault: true,
});
},
deserialize<ReturnedValue extends Value>(
deserialize: (value: string) => ReturnedValue
) {
return list<
ReturnedValue,
Key,
Def & { deserialize: (value: string[]) => ReturnedValue[] }
>(key, {
...def,
deserialize: (value: string[]) =>
value.map((item) => deserialize(item)),
});
},
serialize<Serialize extends (value: Value) => string>(
serialize: Serialize
) {
return list<
Parameters<Serialize>[0],
Key,
Def & { serialize: (value: Parameters<Serialize>[0][]) => string[] }
>(key, {
...def,
serialize: (value: Parameters<Serialize>[0][]) =>
value.map((item) => serialize(item)),
});
},
build(): {
[P in Key]: Def;
} {
return { [key]: def } as any;
},
};
}
import { FilterConfig } from './types';
export function param<Value, Key extends string, Def = { isList: false }>(
key: Key,
def = { isList: false } as {}
) {
return {
start() {
return param<Value, Key, Def & { type: 'start' }>(key, {
...def,
type: 'start',
});
},
filter(filter: FilterConfig) {
return param<Value, Key, Def & { type: 'filter'; filter: FilterConfig }>(
key,
{
...def,
type: 'filter',
filter,
}
);
},
default<DefaultValue extends Value>(value: DefaultValue) {
return param<DefaultValue, Key, Def & { default: DefaultValue }>(key, {
...def,
default: value,
});
},
optionalDefault<DefaultValue extends Value>(value: DefaultValue) {
return param<
DefaultValue,
Key,
Def & { default: DefaultValue; optionalDefault: true }
>(key, {
...def,
default: value,
optionalDefault: true,
});
},
deserialize<ReturnedValue extends Value>(
deserialize: (value: string | null) => ReturnedValue
) {
return param<
ReturnedValue,
Key,
Def & { deserialize: (value: string | null) => ReturnedValue }
>(key, {
...def,
deserialize,
});
},
serialize<Serialize extends (value: Value) => string>(
serialize: Serialize
) {
return param<
Parameters<Serialize>[0],
Key,
Def & { serialize: Serialize }
>(key, {
...def,
serialize,
});
},
build(): {
[P in Key]: Def;
} {
return { [key]: def } as any;
},
};
}
export function number<Key extends string>(key: Key) {
return param<number, Key>(key).deserialize(Number);
}
export function optionalNumber<Key extends string>(key: Key) {
return param<number | null, Key>(key).deserialize((value) =>
value === null ? null : Number(value)
);
}
export function boolean<Key extends string>(key: Key) {
return param<boolean, Key>(key).deserialize((value) => value === 'true');
}
export function start<Key extends string>(key: Key) {
return number<Key>(key).default(0).start();
}
export type FilterConfig = { label: string };
// infer values from all params
// 1) Given an object containing parameter definitions, check each definition.
export type InferValues<T> = {
[K in keyof T]: InferValueFromDef<T[K]>;
};
// 2) For a single parameter definition, check if its a list or single value.
type InferValueFromDef<T> = T extends { isList: true }
? InferValueFromDefList<T>
: InferValueFromDefValue<T>;
// 3.1) If it's a list, get the value type from `deserialize`, `_cast` or fallback to `string[]`.
type InferValueFromDefList<T> = T extends {
deserialize: (value: string[]) => infer R;
}
? R
: T extends {
_cast: infer R;
}
? R[]
: T extends {
default: infer R;
}
? R
: string[];
// 3.2) If it's a single value, get the value type from `deserialize`, `default` or fallback to `string | null`.
type InferValueFromDefValue<T> = T extends {
deserialize: (value: string) => infer R;
}
? R
: T extends {
default: infer R;
optionalDefault: true;
}
? R | null
: T extends {
default: infer R;
}
? R
: string | null;
// get all keys which belong to a filter param
// type IsString<Key> = Key extends string ? Key : never;
export type FilterKeys<T> = {
[K in keyof T]: T[K] extends { type: 'filter' } ? K : never;
}[keyof T];
// infer all filter params
export type InferFilters<T> = Pick<InferValues<T>, FilterKeys<T>>;
// take the 'defs' from a hook and infer all filters
// export type HookFilters<T> = T extends { defs: infer R }
// ? InferFilters<R>
// : never;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment