Generic URL State Controller
- url
- state management
- generics
- context api
- TypeScript
9 min read 1829 words
Store
<script lang="ts">
import { page } from '$app/stores';
import { Switch } from '$lib/components';
import { SearchBar, Price, SortBy } from '$routes/shop/components';
import { createSearchAndFilterService, useSearchAndFilterService } from '$routes/shop/services';
createSearchAndFilterService();
const {
params: { query, available, price, sortBy },
reset,
} = useSearchAndFilterService();
</script>
<div class="flex h-full flex-col justify-around gap-8">
<div class="mx-auto w-fit rounded-full border border-accent-9 bg-accent-9/25 px-4 py-2 font-mono">
<span>$page.url.search: </span>
<span class="break-all text-gray-11">{$page.url.search}</span>
</div>
<div class="space-y-4 sm:space-y-0">
<p class="mb-2 text-2xl">Store</p>
<div>
<span class="block w-32 text-lg sm:inline-block">Query: </span>
<span class="break-words font-mono text-gray-11">{$query}</span>
</div>
<div>
<span class="block w-32 text-lg sm:inline-block">Available: </span>
<span class="break-words font-mono text-gray-11">{$available}</span>
</div>
<div>
<span class="block w-32 text-lg sm:inline-block">Price: </span>
<span class="break-words font-mono text-gray-11">{JSON.stringify($price)}</span>
</div>
<div>
<span class="block w-32 text-lg sm:inline-block">Sort By: </span>
<span class="break-words font-mono text-gray-11">{JSON.stringify($sortBy)}</span>
</div>
</div>
<div class="grid gap-8 sm:grid-cols-2">
<div class="flex w-full flex-col gap-4">
<button class="btn btn-hollow w-full" onclick={() => reset({ filtersOnly: true })}>Remove Filters</button>
<button class="btn btn-hollow w-full" onclick={() => reset()}>Reset Filters and Sorting</button>
</div>
<div class="flex w-full flex-col gap-4">
<div class="flex h-full items-center gap-4">
<label class="text-sm font-bold" for="in-stock-only" id="in-stock-only-label">In Stock Only</label>
<Switch state={$available} id="in-stock-only" onClick={available.toggle} />
</div>
<SortBy />
</div>
<div class="space-y-2">
<span class="text-sm font-bold">Query</span>
<SearchBar
onsubmit={(e) => {
e.preventDefault();
query.set(e.currentTarget.querySelector('input')?.value ?? null);
}}
/>
</div>
<Price />
</div>
</div>
import { derived, get, type Readable } from 'svelte/store';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { createFlagParam, createSelectParam, createMinMaxParams, createStringParam } from '$lib/stores';
import { defineContext } from '$lib/utils/client';
import { type SortItem, defaultSortItem, maxPrice, paramNames, sortItems } from './consts';
const [getService, setService] = defineContext<{
params: {
sortBy: ReturnType<typeof createSelectParam<SortItem['value']>>;
query: ReturnType<typeof createStringParam>;
available: ReturnType<typeof createFlagParam>;
price: ReturnType<typeof createMinMaxParams>;
};
reset: (a?: { skipGo?: true; filtersOnly?: true }) => void;
pushToUrl: () => void;
pullFromUrl: () => void;
filterCount: Readable<number>;
}>();
const createSearchAndFilterService = () => {
const params = {
available: createFlagParam({ paramName: paramNames.availability, skipInitGoto: true }),
sortBy: createSelectParam(
{ defaultValue: defaultSortItem, paramName: paramNames.sortBy, skipInitGoto: true },
{ options: sortItems },
),
query: createStringParam({ paramName: paramNames.query, skipInitGoto: true }),
price: createMinMaxParams(
{ skipInitGoto: true, paramNameMap: { min: paramNames.price.min, max: paramNames.price.max } },
{ absMax: maxPrice },
),
};
const pullFromUrl = () => {
if (!browser) return;
Object.values(params).forEach((param) => param.pullFromUrl());
};
const pushToUrl = () => {
if (!browser) return;
const mutUrl = new URL(get(page).url);
Object.values(params).forEach((param) => param.pushToUrl(mutUrl));
const oldUrl = new URL(window.location.href);
if (mutUrl.href !== oldUrl.href) goto(mutUrl, { keepFocus: true, noScroll: true, replaceState: true });
};
const reset = ({ skipGo, filtersOnly }: { skipGo?: true; filtersOnly?: true } = {}) => {
let changed = false;
if (!filtersOnly && params.sortBy.setCleaned(defaultSortItem, { nogo: true }).changed) changed = true;
if (params.query.setCleaned(null, { nogo: true }).changed) changed = true;
if (params.available.setCleaned(null, { nogo: true }).changed) changed = true;
if (params.price.setCleaned({ min: null, max: null }, { nogo: true }).changed) changed = true;
if (changed && !skipGo) {
pushToUrl();
}
};
const filterCount = derived([params.query, params.available, params.price], ([$query, $available, $price]) => {
let count = 0;
if ($query !== null) count++;
if ($available !== null) count++;
if ($price.min !== null || $price.max !== null) count++;
return count;
});
if (Object.values(params).some((param) => param.initiallyOutOfSync)) {
pushToUrl();
}
setService({
params,
pullFromUrl,
pushToUrl,
reset,
filterCount,
});
};
export { createSearchAndFilterService, getService as useSearchAndFilterService };
import { get } from 'svelte/store';
import { createParam, createParams } from './paramsGeneric';
type BaseArgs<V, K extends string = string> = Pick<
Parameters<typeof createParam<V, K>>[0],
'paramName' | 'skipInitGoto'
> & { defaultValue?: V };
type BaseArgsP<V, K extends string = string> = Pick<
Parameters<typeof createParams<V, K>>[0],
'paramNameMap' | 'skipInitGoto'
> & { defaultValue?: Record<K, V> };
export const createStringParam = (a: BaseArgs<string | null>) => {
return createParam<string | null>({
defaultValue: null,
...a,
serialize: (cleanVal) => cleanVal,
deserialize: (paramVal) => paramVal || null,
clean: (uncleanVal) => (typeof uncleanVal === 'string' ? uncleanVal || null : null),
});
};
export const createNullableBoolParam = (a: BaseArgs<boolean | null>) => {
// ParamVal: 'true' | 'false' | null
const param = createParam<boolean | null>({
defaultValue: null,
...a,
serialize: (cleanVal) => (cleanVal === true ? 'true' : cleanVal === false ? 'false' : null),
deserialize: (paramVal) => (paramVal === 'true' ? true : paramVal === 'false' ? false : null),
clean: (uncleanVal) => (typeof uncleanVal === 'boolean' ? uncleanVal : null),
}) as ReturnType<typeof createParam<boolean | null>> & {
toggle: () => void;
toggleNull: () => void;
};
param.toggle = () => param.set(get(param) === true ? false : true);
param.toggleNull = () => param.set(get(param) === true ? null : true);
return param;
};
export const createFlagParam = (a: BaseArgs<true | null>) => {
// ParamVal: 'true' | null
const param = createParam<true | null>({
defaultValue: null,
...a,
serialize: (cleanVal) => (cleanVal === true ? 'true' : null),
deserialize: (paramVal) => (paramVal === 'true' ? true : null),
clean: (uncleanVal) => (uncleanVal === true ? true : null),
}) as ReturnType<typeof createParam<true | null>> & {
toggle: () => void;
};
param.toggle = () => param.set(get(param) === true ? null : true);
return param;
};
export const createNumParam = (
a: BaseArgs<number>,
opts: { min?: number; max?: number; wrap?: never } | { min: number; max: number; wrap: true } = {},
) => {
const { max, min, wrap } = opts;
const clean = (uncleanVal: number): number => {
if (typeof uncleanVal !== 'number' || isNaN(uncleanVal)) return min ?? 0;
if (wrap) {
if (uncleanVal === min - 1) return max;
if (uncleanVal === max + 1) return min;
}
if (min !== undefined && uncleanVal < min) return min;
if (max !== undefined && uncleanVal > max) return max;
return uncleanVal;
};
const deserialize = (uncleanVal: string | null): number => {
if (typeof uncleanVal !== 'string') return min ?? 0;
const num = parseInt(uncleanVal, 10);
return clean(num);
};
const param = createParam<number>({
defaultValue: 0,
...a,
serialize: (val) => (val === 0 ? null : val.toString()),
deserialize,
clean,
}) as ReturnType<typeof createParam<number>> & {
prev: () => void;
next: () => void;
};
param.prev = () => param.set(get(param) - 1);
param.next = () => param.set(get(param) + 1);
return param;
};
export const createSelectParam = <Value extends { paramName: string | null }>(
a: BaseArgs<{ label: string; value: Value }>,
{ options }: { options: { label: string; value: Value }[] },
) => {
type SortItem = { label: string; value: Value };
const hasParamName = (maybe: unknown): maybe is { value: { paramName: string } } => {
try {
if (typeof ((maybe ?? {}) as { value: { paramName: string } })?.value?.paramName !== 'string') return false;
} catch (_) {
return false;
}
return true;
};
const defaultValue = a.defaultValue ?? options[0]!;
const param = createParam<SortItem>({
defaultValue,
...a,
serialize: (val) => val.value.paramName,
deserialize: (paramVal) => options.find((i) => i.value.paramName === paramVal) ?? defaultValue,
clean: (uncleanVal) => {
if (!hasParamName(uncleanVal)) return defaultValue;
return options.find((i) => i.value.paramName === uncleanVal.value.paramName) ?? defaultValue;
},
}) as ReturnType<typeof createParam<SortItem>> & {
items: SortItem[];
};
param.items = options;
return param;
};
const strToNum = (s?: string | null): number | null => {
if (!s) return null;
const stripped = s.replace(/[^0-9]/g, '');
if (stripped === '') return null;
const num = parseInt(stripped, 10);
if (isNaN(num)) return null;
return num;
};
const withinBounds = ({
num,
absMax,
absMin,
}: {
num: number | null;
absMax?: number;
absMin?: number;
}): number | null => {
if (num === null) return null;
return Math.max(absMin ?? 0, Math.min(absMax ?? Infinity, num));
};
export const cleanMinMax: (a: {
absMax?: number;
curMax?: number | null;
curMin?: number | null;
newMax?: number | null;
newMin?: number | null;
}) => { min: number | null; max: number | null } = ({ absMax, curMax, curMin, newMax, newMin }) => {
const cleanMin = withinBounds({ num: newMin === undefined ? (curMin ?? null) : newMin, absMax });
const cleanMax = withinBounds({ num: newMax === undefined ? (curMax ?? null) : newMax, absMax });
if (cleanMin === null || cleanMax === null || cleanMax >= cleanMin) {
return { min: cleanMin, max: cleanMax };
}
if (newMin !== undefined && newMax !== undefined) {
return { min: cleanMin, max: null }; // arbitrary choice
}
if (newMin !== undefined && newMax === undefined) {
return { min: cleanMin, max: cleanMin };
}
if (newMin === undefined && newMax !== undefined) {
return { min: cleanMax, max: cleanMax };
}
return { min: null, max: null }; // curMin and curMax are invalid
};
export const cleanMinMaxStr = (a: {
absMax?: number;
curMax?: number | null;
curMin?: number | null;
newMax?: string | null;
newMin?: string | null;
}) => {
return cleanMinMax({
absMax: a.absMax,
curMax: a.curMax,
curMin: a.curMin,
newMax: typeof a.newMax === 'string' ? strToNum(a.newMax) : a.newMax,
newMin: typeof a.newMin === 'string' ? strToNum(a.newMin) : a.newMin,
});
};
export const createMinMaxParams = (a: BaseArgsP<number | null, 'min' | 'max'>, { absMax }: { absMax: number }) => {
type Key = 'min' | 'max';
type Value = number | null;
const res = createParams<Value, Key>({
defaultValue: { min: null, max: null },
...a,
serializeOne: (a) => (a === null ? a : a.toString()),
deserialize: ({ old, uncleanParamVals }) =>
cleanMinMaxStr({
curMin: old.min,
curMax: old.max,
absMax,
newMin: uncleanParamVals.min,
newMax: uncleanParamVals.max,
}),
clean: (unclean) => cleanMinMax({ absMax, newMax: unclean.max, newMin: unclean.min }),
}) as ReturnType<typeof createParams<Value, Key>> & {
absMax: number;
};
res.absMax = absMax;
return res;
};
import { get, writable, type Readable } from 'svelte/store';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
type Changed = { changed: boolean };
type GoOpts = { root?: string; deleteOtherParams?: true };
type MaybeGoOpts = { nogo: true; root?: never; deleteOtherParams?: never } | ({ nogo?: never } & GoOpts);
type ParamVal = string | null;
interface ParamGeneric<Val, ParamName extends string> {
paramName: ParamName;
getParam: () => ParamVal;
subscribe: Readable<Val>['subscribe'];
serialize: (cleanVal: Val) => ParamVal;
deserialize: (uncleanParamVal: ParamVal) => Val;
clean: (uncleanVal: Val) => Val;
pushToUrl: (mutUrl: URL) => URL;
pullFromUrl: () => void;
setParam: (paramVal: ParamVal, opts?: MaybeGoOpts) => Changed;
set: (uncleanVal: Val, opts?: MaybeGoOpts) => Changed;
update: (cb: (old: Val) => Val) => void;
setCleaned: (cleaned: Val, opts?: MaybeGoOpts) => Changed;
isSynced: (a?: { paramVal?: ParamVal; val?: Val }) => boolean;
initiallyOutOfSync: boolean;
}
const createUrl = ({ root, deleteOtherParams }: GoOpts = {}): URL => {
const url = new URL(get(page).url);
if (root) url.pathname = `${root.startsWith('/') ? '' : '/'}${root}`;
if (deleteOtherParams) url.search = '';
return url;
};
export const createParam = <Val, ParamName extends string = string>({
paramName,
defaultValue,
skipInitGoto,
serialize,
deserialize,
clean,
}: Pick<ParamGeneric<Val, ParamName>, 'clean' | 'serialize' | 'deserialize'> & {
paramName: ParamName;
defaultValue: Val;
skipInitGoto?: true;
}): ParamGeneric<Val, ParamName> => {
type Res = ParamGeneric<Val, ParamName>;
const store = writable<Val>(defaultValue);
const getParam: Res['getParam'] = () => {
return get(page).url.searchParams.get(paramName);
};
const pushToUrl: Res['pushToUrl'] = (mutUrl) => {
const currentVal = get(store);
const currentParam = serialize(currentVal);
if (currentParam === null) mutUrl.searchParams.delete(paramName);
else mutUrl.searchParams.set(paramName, currentParam);
return mutUrl;
};
const goImmediate = (opts?: GoOpts): void => {
goto(pushToUrl(createUrl(opts)), { keepFocus: true, noScroll: true, replaceState: true });
};
const go = (() => {
let timer: ReturnType<typeof setTimeout>;
return (opts?: GoOpts) => {
clearTimeout(timer);
timer = setTimeout(() => goImmediate(opts), 50);
};
})();
const isSynced: Res['isSynced'] = (a) => {
const paramVal = a?.paramVal ?? getParam();
const val = a?.val ?? get(store);
return paramVal === serialize(val);
};
const setCleaned: Res['setCleaned'] = (cleaned, opts) => {
store.set(cleaned);
const changed = !isSynced();
if (changed && !opts?.nogo) go(opts);
return { changed };
};
const set: Res['set'] = (unclean, opts) => setCleaned(clean(unclean), opts);
const update: Res['update'] = (cb) => set(cb(get(store)));
const setParam: Res['setParam'] = (param, opts) => {
return setCleaned(deserialize(param), opts);
};
const pullFromUrl: Res['pullFromUrl'] = () => {
setParam(getParam());
};
const init = (): boolean => {
const paramVals = getParam();
store.set(deserialize(paramVals));
const changed = !isSynced();
if (changed && browser && !skipInitGoto) {
goImmediate();
}
return changed;
};
return {
paramName,
getParam,
subscribe: store.subscribe,
serialize,
clean,
pushToUrl,
pullFromUrl,
setParam,
set,
update,
deserialize,
setCleaned,
isSynced,
initiallyOutOfSync: init(),
};
};
interface ParamsGeneric<
Val,
ParamName extends string,
Params = Record<ParamName, ParamVal>,
Store = Record<ParamName, Val>,
> {
paramNameMap: Record<ParamName, string>;
paramKeys: ParamName[];
getParams: () => Partial<Params>;
subscribe: Readable<Store>['subscribe'];
serializeOne: (cleanVal: Val) => ParamVal;
deserialize: (uncleanParamVals: Partial<Params>) => Store;
clean: (unclean: Partial<Store>) => Store;
pushToUrl: (mutUrl: URL) => URL;
pullFromUrl: () => void;
setParams: (param: Partial<Params>, opts?: MaybeGoOpts) => Changed;
set: (unclean: Partial<Store>, opts?: MaybeGoOpts) => Changed;
update: (cb: (old: Store) => Store) => void;
setCleaned: (cleaned: Store, opts?: MaybeGoOpts) => Changed;
isSynced: (a?: { paramVal?: Partial<Params>; val?: Store }) => boolean;
initiallyOutOfSync: boolean;
}
export const createParams = <Val, ParamName extends string = string>({
clean,
defaultValue,
paramNameMap,
serializeOne,
deserialize: _deserialize,
skipInitGoto,
}: Pick<ParamsGeneric<Val, ParamName>, 'serializeOne' | 'clean'> & {
paramNameMap: Record<ParamName, string>;
defaultValue: Record<ParamName, Val>;
skipInitGoto?: true;
deserialize: (a: {
old: Record<ParamName, Val>;
uncleanParamVals: Partial<Record<ParamName, ParamVal>>;
}) => Record<ParamName, Val>;
}): ParamsGeneric<Val, ParamName> => {
type Res = ParamsGeneric<Val, ParamName>;
const deserialize = (uncleanParamVals: Partial<Record<ParamName, ParamVal>>) =>
_deserialize({ old: get(store), uncleanParamVals });
const paramKeys: Res['paramKeys'] = Object.keys(paramNameMap) as ParamName[];
const store = writable<Record<ParamName, Val>>(defaultValue);
const getParams: Res['getParams'] = () => {
const url = get(page).url;
return paramKeys.reduce((total, key) => ({ ...total, [key]: url.searchParams.get(paramNameMap[key]) }), {});
};
const pushToUrl: Res['pushToUrl'] = (mutUrl) => {
const currentVal = get(store);
paramKeys.forEach((key) => {
const currentParam = serializeOne(currentVal[key]);
const paramName = paramNameMap[key];
if (currentParam === null) mutUrl.searchParams.delete(paramName);
else mutUrl.searchParams.set(paramName, currentParam);
});
return mutUrl;
};
const isSynced: Res['isSynced'] = (a = {}) => {
const paramVal = a.paramVal ?? getParams();
const val = a.val ?? get(store);
return paramKeys.every((key) => paramVal[key] === serializeOne(val[key]));
};
const goImmediate = (opts?: GoOpts): void => {
goto(pushToUrl(createUrl(opts)), { keepFocus: true, noScroll: true, replaceState: true });
};
const go = (() => {
let timer: ReturnType<typeof setTimeout>;
return (opts?: GoOpts) => {
clearTimeout(timer);
timer = setTimeout(() => goImmediate(opts), 50);
};
})();
const setCleaned: Res['setCleaned'] = (cleaned, opts) => {
store.set(cleaned);
const changed = !isSynced();
if (changed && !opts?.nogo) go(opts);
return { changed };
};
const set: Res['set'] = (unclean, opts) => setCleaned(clean(unclean), opts);
const update: Res['update'] = (cb) => set(cb(get(store)));
const setParams: Res['setParams'] = (param, opts) => {
return setCleaned(deserialize(param), opts);
};
const pullFromUrl: Res['pullFromUrl'] = () => {
setParams(getParams());
};
const init = (): boolean => {
const paramVals = getParams();
store.set(deserialize(paramVals));
const changed = !isSynced();
if (changed && browser && !skipInitGoto) {
goImmediate();
}
return changed;
};
return {
paramNameMap,
paramKeys,
getParams,
subscribe: store.subscribe,
serializeOne,
deserialize,
clean,
pushToUrl,
pullFromUrl,
setParams,
set,
update,
setCleaned,
isSynced,
initiallyOutOfSync: init(),
};
};
<script lang="ts">
import { Switch } from '$lib/components';
import { useSearchAndFilterService } from '$routes/shop/services';
const {
params: { available },
} = useSearchAndFilterService();
</script>
<div class="space-y-2">
<label class="text-sm font-bold" for="in-stock-only" id="in-stock-only-label">In Stock Only</label>
<Switch state={$available} id="in-stock-only" classes="block" onClick={available.toggle} />
</div>
<script lang="ts">
import { useSearchAndFilterService } from '$routes/shop/services';
const {
params: { query },
reset,
} = useSearchAndFilterService();
</script>
{#if $query}
<p class="text-2xl">No results for <span class="font-bold">{$query}</span></p>
<p class="flex flex-wrap items-center gap-2 text-xl">
<button class="btn btn-accent inline-block" onclick={() => reset({ filtersOnly: true })}>Erase All Filters</button>
</p>
{:else}
<p class="py-3 text-lg">
Nothing here!
<button class="btn btn-hollow" onclick={() => reset({ filtersOnly: true })}>Remove Filters</button>
</p>
{/if}
<script lang="ts">
import { cap } from '$lib/utils/common';
import { useSearchAndFilterService } from '$routes/shop/services';
const {
params: { price },
} = $state(useSearchAndFilterService());
</script>
<div class="space-y-2">
<span class="text-sm font-bold">Price</span>
<div class="flex items-center gap-2">
{#each price.paramKeys as paramKey, i}
<input
type="number"
class="input-text"
min="0"
aria-label={paramKey}
max={price.absMax}
placeholder={cap(paramKey)}
value={$price[paramKey]}
onkeydown={(e) => {
if (['-', '+', 'e', 'E', '.'].includes(e.key)) e.preventDefault();
if (e.currentTarget.value.length === price.absMax.toString().length && e.key.match(/[0-9]/)) {
price.set({ [paramKey]: price.absMax }, { nogo: true });
e.preventDefault();
}
}}
onpaste={(e) => {
e.preventDefault();
price.setParams({ [paramKey]: e.clipboardData?.getData('text') ?? null });
}}
onchange={(e) => price.setParams({ [paramKey]: e.currentTarget.value })}
/>
{#if !i}
<span class="text-sm text-gray-8">–</span>
{/if}
{/each}
</div>
</div>
<script lang="ts">
import I from '$lib/icons';
import { useSearchAndFilterService } from '$routes/shop/services';
const {
params: { query },
} = useSearchAndFilterService();
interface Props {
onsubmit: (e: SubmitEvent & { currentTarget: EventTarget & HTMLFormElement }) => void;
}
const { onsubmit }: Props = $props();
</script>
<div class="relative">
<form method="get" action="/" {onsubmit}>
<input
name="query"
type="text"
class="input-text"
placeholder="Search for products..."
autocomplete="off"
value={$query}
aria-label="Search for products."
/>
<button aria-label="Submit search query" type="submit" class="absolute right-0 top-0 mr-3 flex h-full items-center">
<I.Search class="h-4" />
</button>
</form>
</div>
<script lang="ts">
import { createSelect } from '@melt-ui/svelte';
import { Select } from '$lib/components';
import { useSearchAndFilterService, type SortItem } from '$routes/shop/services';
const {
params: { sortBy },
} = useSearchAndFilterService();
const select = createSelect<SortItem['value']>({ selected: sortBy });
</script>
<Select ariaLabel="Sort By" title="Sort by" {select} options={{ 'Sort by': sortBy.items }} />
This is part 2 of 2 in the URL State Controller series.
URL State Controller
Part 2 of 2 in the URL State Controller series.
- 01. Simple URL State Controller
- 02. Generic URL State Controller
We previously made a simple URL state controller whose state was
derived from $page.url.searchParams
. Creating and using it is as simple as a regular store.
<script lang="ts">
import { writable } from 'svelte/store'
import { searchParams, searchParam } from '$lib/stores';
const store = writable<string | null>(null)
$store = 'hello';
const storeArr = writable<string[]>([])
$storeArr = ['hello', 'world'];
const param = searchParam('param'); // $param is string | null
$param = 'hello'; // navigates to ?param=hello
const paramsStore = searchParams('params'); // $paramsStore is string[]
$paramsStore = ['hello', 'world']; // navigates to ?params=hello¶ms=world
</script>
<input type="text" bind:value={$param} />
<!-- or -->
<input type="text" value={$param} onblur={(e) => ($param = e.currentTarget.value)} />
However, as discussed in the conclusion, the derived state implementation has pros and cons. It's simple, and the url / store state are always in sync, but let's consider a more complex example to investigate the drawbacks.
Drawbacks of the derived store
We want to query a shopify endpoint. The search and filter state should be stored in the url. ?sort_by=latest-desc&availability=true&price.gte=10&price.lte=100&q=shirt
should be represented
as:
{
reverse: true,
sortKey: 'CREATED_AT',
availability: true,
price: { min: 10, max: 100 },
title: 'shirt'
}
In our previous implementation, the data is derived
from page
. This has a couple
implications.
If there are invalid values in the url when the page first loads, we have to make a choice about whether the state of the store in the brief moment before our init function runs should be unclean or out of sync with
$page
. By making the state aderived
store without a deserialize function, we implicitly chose that it should be unclean.The state's type is
string | null
orstring[]
.- We don't want to trap ourselves with JS type coercion by writing
if ($availability)
and always receivingtrue
because"false"
evaluates truthy. Booleans should be encoded asboolean
. - We can't use any apis that require another type, so passing the store into melt-ui, for example, is impossible.
- Logically linked states have no clean way of being stored together. For example,
price.min > price.max
should be "unclean". In order to write this cleaning logic, a consumer would either have to serialize both states into one as?price=10,100
and deserialize them in the inputs, or define them separately and wrap them in a function that controls their relationship and updates them with.mutateSearchParams()
instead of.set()
. That's messy, and shouldn't be the consumer's responsibility.
- We don't want to trap ourselves with JS type coercion by writing
Debouncing url changes becomes difficult because that requires debouncing the store update, overwriting user input that arrives faster than the debounce period. By not using
derived
, we can debounce and add effects that are invalidated when the url changes. For example, if there was aload
function that referencedurl
, and thesearchParam
was on a number input, holding the up arrow would cause a massive number of load function invalidations. By decoupling the store from the URL state, we can immediately update the store and debounce the URL to ensure that the load function only runs when the user stops updating the params.
Single Value Generic Param
Like in the previous article, we'll start by deciding what we need and building up an interface, and like last time,
we'll want two interfaces – one for single params and another for multiple. In the last implementation, single meant string | null
and multiple meant string[]
because they were tied to the URLSearchParams
object. Now that we are free of that constraint, single will mean anything that a user will
need a single input for, and multiple will mean anything that a user will need multiple inputs for. Let's again start with
the simpler single param interface.
Building the Interface
Getters
We know the base interface will definitely need to be generic over at least one type – our generic value type. That
might be a boolean
switch, a string
query, a ≥ 0 number
price, or anything
else that will be presented to the user via a single input. Let's call that generic type Val
.
{ min, max }
price – later.interface ParamGeneric<Val> {
}
type ParamVal = string | null
, so let's add a convenience function to get that value. It will also be
nice to encode the name of the param into the type as a string literal, so let's add our a second generic param as
well.
type ParamVal = string | null;
interface ParamGeneric<Val, ParamName extends string> {
paramName: ParamName;
getParam: () => ParamVal;
}
The validated source of truth will be in a Svelte store, so we'll add a subscribe
function to get our store
value.
type ParamVal = string | null;
interface ParamGeneric<Val, ParamName extends string> {
paramName: ParamName;
getParam: () => ParamVal;
subscribe: Readable<Val>['subscribe'];
}
Serializers
Since we'll be working with both Val
and ParamVal
, we'll need serialize Val => ParamVal
and deserialize ParamVal => Val
functions to translate between them.
type ParamVal = string | null;
interface ParamGeneric<Val, ParamName extends string> {
paramName: ParamName;
getParam: () => ParamVal;
subscribe: Readable<Val>['subscribe'];
serialize: (val: Val) => ParamVal;
deserialize: (paramVal: ParamVal) => Val;
}
The serialize function won't need to think about validation logic. The store will be the source of truth and always
contain a validated state. However, we must validate the input before it gets set into the store. We could add a clean
function that we will call after we deserialize, and rename deserialize
to deserializeToUnclean
to make it clear that it's not a validation function. Then we'd sanitize param
values by using clean(deserializeToUnclean(paramVal))
. However, many times the clean
function would be nearly identical to the deserializeToUnclean
function and result in twice the function
calls. Instead, we'll expect both a clean
function and a separate deserialize
function which
validates. The consumer can then compose the validation logic however they want without risk of duplication.
type ParamVal = string | null;
interface ParamGeneric<Val, ParamName extends string> {
paramName: ParamName;
getParam: () => ParamVal;
subscribe: Readable<Val>['subscribe'];
serialize: (cleanVal: Val) => ParamVal;
deserialize: (uncleanParamVal: ParamVal) => Val;
clean: (uncleanVal: Val) => Val;
}
Setters
We'll need a way to mutate a URL to add/delete our param without navigating, so we'll add a pushToUrl
function. Likewise, we'll need a way to pull the current URLSearchParam
into our store: pullFromUrl
.
type ParamVal = string | null;
interface ParamGeneric<Val, ParamVal, StoreVal> {
paramName: ParamName;
getParam: () => ParamVal;
subscribe: Readable<Val>['subscribe'];
serialize: (cleanVal: Val) => ParamVal;
deserialize: (uncleanParamVal: ParamVal) => Val;
clean: (uncleanVal: Val) => Val;
pushToUrl: (mutUrl: URL) => URL;
pullFromUrl: () => void;
}
The ParamVal
type string | null
is useful for working with inputs, so let's add a setParam
method that will call deserialize
.
If we already have a Val
, we'll want to clean it before setting it. This will be the default option, so
we'll call this method set
and it will call clean
. We might as well include a Svelte update
method as well.
If we know we already have a cleaned Val
, we'll want to set it directly without having to run clean
unnecessarily. We'll add a setCleaned
method for that.
In our simple implementation, we defined type GoOpts = { absolute?: string }
. There was no way
for us to update the store value without using goto
, but that's no longer true. Therefore, let's change
our opts
parameter to use MaybeGoOpts
which allow us to update the store without updating the
url.
type Changed = { changed: boolean };
type GoOpts = { root?: string; deleteOtherParams?: true };
type MaybeGoOpts = { nogo: true; root?: never; deleteOtherParams?: never } | ({ nogo?: never } & GoOpts);
type ParamVal = string | null;
interface ParamGeneric<Val, ParamName extends string> {
paramName: ParamName;
getParam: () => ParamVal;
subscribe: Readable<Val>['subscribe'];
serialize: (cleanVal: Val) => ParamVal;
deserialize: (uncleanParamVal: ParamVal) => Val;
clean: (uncleanVal: Val) => Val;
pushToUrl: (mutUrl: URL) => URL;
pullFromUrl: () => void;
setParam: (paramVal: ParamVal, opts?: MaybeGoOpts) => Changed;
set: (uncleanVal: Val, opts?: MaybeGoOpts) => Changed;
update: (cb: (old: Val) => Val) => void;
setCleaned: (cleaned: Val, opts?: MaybeGoOpts) => Changed;
}
Sync State
Unlike the implementation using derive
, the store and url may be out of sync. We should add an isSynced
method to check that. Also, as we'll see later, it will be useful to know if the store is out of
sync when it first initializes, so we'll add an initiallyOutOfSync
property.
type Changed = { changed: boolean };
type GoOpts = { root?: string; deleteOtherParams?: true };
type MaybeGoOpts = { nogo: true; root?: never; deleteOtherParams?: never } | ({ nogo?: never } & GoOpts);
type ParamVal = string | null;
interface ParamGeneric<Val, ParamName extends string> {
paramName: ParamName;
getParam: () => ParamVal;
subscribe: Readable<Val>['subscribe'];
serialize: (cleanVal: Val) => ParamVal;
deserialize: (uncleanParamVal: ParamVal) => Val;
clean: (uncleanVal: Val) => Val;
pushToUrl: (mutUrl: URL) => URL;
pullFromUrl: () => void;
setParam: (paramVal: ParamVal, opts?: MaybeGoOpts) => Changed;
set: (uncleanVal: Val, opts?: MaybeGoOpts) => Changed;
update: (cb: (old: Val) => Val) => void;
setCleaned: (cleaned: Val, opts?: MaybeGoOpts) => Changed;
isSynced: (a?: { paramVal?: ParamVal; val?: Val }) => boolean;
initiallyOutOfSync: boolean;
}
Implementation
We know what we have to do. Here's one way of getting there:
Pretty straightforward once the interface is decided.
defaultValue
is the temporary value until the init function runs. This allows us to make sure the store is always validated.skipInitGoto
can be useful if multiple params are on the same page and we want to wait for all of them to initialize before redirecting.- We can update the store and URL separately, so we can now debounce
go
without ruining the user's input experience.
Multi Value Generic Param
Interface
This is not synonymous with $page.url.searchParams.getAll
. Instead, we will map out grouped params and
assign them each a param name. For example, { foo: 'f', bar: 'b' }
will indicate the controller
stores an object { foo: Val, bar: Val }
and the url params will be f
and b
.
interface ParamsGeneric<Val, ParamName extends string> {
paramNameMap: Record<ParamName, string>;
paramKeys: ParamName[];
}
The rest of our interface will be very similar to the single param interface. However, whereas before the store
contained Val
, it now contains Record<ParamName, Val>
. Likewise, whereas before we
accepted a ParamVal
, we now accept a Record<ParamName, ParamVal>
.
Implementation
And a possible implementation:
Concrete Types
Now that we have a generic factory, it's easy to create some concrete types:
Conclusion
We've created a more flexible, generic implementation for our url state controller and now we can compose multiple together to create rich services like the one used in the demo. You can see it in action in the demo shop. Hope the article has been useful for you! Have any questions, comments, or want to share your own implementation? Reach out in the GitHub discussions!
This is part 2 of 2 in the URL State Controller series.
URL State Controller
Part 2 of 2 in the URL State Controller series.
- 01. Simple URL State Controller
- 02. Generic URL State Controller