Skip to content

Instantly share code, notes, and snippets.

@simonrelet
Last active November 5, 2023 12:27
Show Gist options
  • Save simonrelet/d30c389c837d78b11aae71121b548c94 to your computer and use it in GitHub Desktop.
Save simonrelet/d30c389c837d78b11aae71121b548c94 to your computer and use it in GitHub Desktop.
import { useSearchParams } from "@remix-run/react";
import isEqual from "lodash.isequal";
import { useCallback, useMemo } from "react";
import type { NavigateOptions } from "react-router";
export function useParsedSearchParams<
TOutput extends object,
TInput extends object,
>(delegate: URLSearchParamsDelegate<TOutput, TInput>) {
const [searchParams, setSearchParams] = useSearchParams();
const parsedSearchParams = useMemo(
() => delegate.parse(searchParams),
[searchParams, delegate],
);
const setParsedSearchParams = useCallback(
(data: TInput, navigateOptions?: NavigateOptions) => {
setSearchParams((searchParams) => {
delegate.set(searchParams, data);
return searchParams;
}, navigateOptions);
},
[setSearchParams, delegate],
);
return [parsedSearchParams, setParsedSearchParams] as const;
}
export class URLSearchParamsDelegate<
TOutput extends object,
TInput extends object,
>
implements Parser<TOutput>, Formatter<TInput>
{
protected parser: URLSearchParamsParser<TOutput>;
protected formatter: URLSearchParamsFormatter<TInput>;
constructor({
parser,
formatter,
}: {
parser: URLSearchParamsParser<TOutput>;
formatter: URLSearchParamsFormatter<TInput>;
}) {
this.parser = parser;
this.formatter = formatter;
}
parse(searchParams: URLSearchParams) {
return this.parser.parse(searchParams);
}
isEmpty(searchParams: URLSearchParams) {
return this.parser.isEmpty(searchParams);
}
areEqual(a: URLSearchParams, b: URLSearchParams) {
return this.parser.areEqual(a, b);
}
format(data: TInput) {
return this.formatter.format(data);
}
set(searchParams: URLSearchParams, data: TInput) {
this.formatter.set(searchParams, data);
}
}
export namespace URLSearchParamsDelegate {
export type Infer<TDelegate extends URLSearchParamsDelegate<any, any>> =
TDelegate extends URLSearchParamsDelegate<infer TOutput, any>
? TOutput
: never;
}
export class URLSearchParamsParser<TOutput extends object>
implements Parser<TOutput>
{
// It's not useless because it defines `parseFunction` as protected instance
// attribute.
// eslint-disable-next-line no-useless-constructor
constructor(protected parseFunction: (data: object) => TOutput) {}
parse(searchParams: URLSearchParams) {
return this.parseFunction(toObject(searchParams));
}
isEmpty(searchParams: URLSearchParams) {
return this.areEqual(searchParams, new URLSearchParams());
}
areEqual(a: URLSearchParams, b: URLSearchParams) {
return isEqual(this.parse(a), this.parse(b));
}
}
interface Parser<TOutput extends object> {
parse(searchParams: URLSearchParams): TOutput;
isEmpty(searchParams: URLSearchParams): boolean;
areEqual(a: URLSearchParams, b: URLSearchParams): boolean;
}
function toObject(searchParams: URLSearchParams): object {
const valuesByKey = new Map<string, unknown[]>();
for (const [key, value] of searchParams) {
const currentValue = valuesByKey.get(key);
if (currentValue == null) {
valuesByKey.set(key, [value]);
} else {
currentValue.push(value);
}
}
return Object.fromEntries(
Array.from(valuesByKey.entries()).map(([key, value]) => [
key,
value.length === 1 ? value[0] : value,
]),
);
}
export class URLSearchParamsFormatter<TInput extends object>
implements Formatter<TInput>
{
// It's not useless because it defines `preprocess` as protected instance
// attribute.
// eslint-disable-next-line no-useless-constructor
constructor(
protected preprocess: (data: TInput) => object = (data) => data,
) {}
format(data: TInput) {
const searchParams = new URLSearchParams();
this.set(searchParams, data);
return searchParams.toString();
}
set(searchParams: URLSearchParams, data: TInput) {
setData(searchParams, this.preprocess(data));
}
}
interface Formatter<TInput extends object> {
format(data: TInput): string;
set(searchParams: URLSearchParams, data: TInput): void;
}
function setData(searchParams: URLSearchParams, data: object) {
Object.entries(data).forEach(([key, value]) => {
// `{ a: undefined }` should not change the `a` value.
if (value === undefined) {
return;
}
// `{ a: null }` should delete the `a` value.
searchParams.delete(key);
if (value == null) {
return;
}
if (Array.isArray(value) || value instanceof Set) {
return value.forEach((value) => {
searchParams.append(key, String(value));
});
}
searchParams.set(key, String(value));
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment