Simple URL State Controller
- url
- state management
12 min read 2672 words
Simple Url State Controller Demo
Filters
Olivia
The mysterious old house creaked in the eerie silence of the night.
Elijah
Sunflowers nodded in agreement as a gentle breeze whispered through the field.
Isabella
The rhythmic sound of raindrops on the roof played a soothing lullaby.
Isabella
A mischievous squirrel darted across the park, stealing glances from curious onlookers.
Olivia
The antique pocket watch ticked with precision, a relic from a bygone era.
Elijah
A kaleidoscope of colors painted the sky as the sun bid farewell to the day.
Xavier
Laughter echoed through the bustling marketplace, creating a lively atmosphere.
Elijah
The scent of freshly baked bread wafted through the air, enticing hungry passersby.
Olivia
A lone wolf howled in the distance, its mournful cry carrying through the night.
Isabella
The magician waved his wand, and a burst of sparks filled the darkened room.
Xavier
Time seemed to stand still as the dancer twirled gracefully on the empty stage.
Xavier
The old bookstore held the musty fragrance of aging paper, a treasure trove for book lovers.
<script lang="ts">
import { derived } from 'svelte/store';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { searchParam, searchParams } from '$lib/stores';
import FilterInputs from './FilterInputs.svelte';
import Posts from './Posts.svelte';
import { loadDummyData } from './demoData';
const data = loadDummyData();
const authors = searchParams('authors');
const content = searchParam('content');
const maxDaysOld = searchParam('max-days', {
clean: (val) => {
const data = val?.replaceAll(/[^0-9.]/g, '');
if (!data) return null;
const num = parseFloat(data);
if (isNaN(num)) return null;
return Math.max(1, Math.min(14, num)).toString();
},
});
const resetFilters = () => {
const mutSearchParams = new URLSearchParams($page.url.search);
content.mutateSearchParams({ mutSearchParams });
maxDaysOld.mutateSearchParams({ mutSearchParams });
authors.mutateSearchParams({ mutSearchParams });
goto(`?${mutSearchParams}`, { keepFocus: true, noScroll: true, replaceState: true });
};
const filteredPosts = derived([authors, content, maxDaysOld], ([$authors, $content, $maxDaysOld]) => {
return data.messages.filter((message) => {
if ($authors.length && !$authors.includes(message.author)) return false;
if ($content && !message.message.includes($content)) return false;
if ($maxDaysOld !== null) {
let days = parseFloat($maxDaysOld);
if (isNaN(days)) days = 0;
const maxDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * days);
if (message.date < maxDaysAgo) return false;
}
return true;
});
});
</script>
<div class="mb-6 space-y-6">
<h2 class="text-center text-accent-11">Simple Url State Controller Demo</h2>
<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>
<FilterInputs authorNames={data.authors} {authors} {content} {maxDaysOld} />
</div>
<Posts posts={$filteredPosts} {resetFilters} />
<script lang="ts">
import { minMaxVal } from '$lib/actions';
import I from '$lib/icons';
import type { SearchParam, SearchParams } from '$lib/stores';
interface Props {
authorNames: string[];
content: SearchParam;
maxDaysOld: SearchParam;
authors: SearchParams;
}
const { authorNames, content, maxDaysOld, authors }: Props = $props();
</script>
<h2 class="text-h4">Filters</h2>
<div>
<span class="relative">
Authors
{#if $authors.length}
<span class="absolute -right-6 -top-2 h-5 w-5"> <I.Badge class="h-full w-full fill-accent-9" /> </span>
<span class="absolute -right-6 -top-2 grid h-5 w-5 place-content-center text-sm text-accent-9-contrast">
{$authors.length}
</span>
{/if}
</span>
<div class="flex flex-wrap gap-2">
{#each authorNames as author}
<button
class="btn {$authors.includes(author) ? 'btn-accent' : 'btn-hollow'}"
onclick={() => authors.updateOne(author, 'toggle')}
>
{author}
</button>
{/each}
<button
class="btn {!$authors.length ? 'btn-accent' : 'btn-hollow'}"
onclick={() => $authors.length && authors.set([])}>All</button
>
</div>
</div>
<label class="block">
Content
<input bind:value={$content} type="text" class="peer input-text" />
</label>
<label class="block">
Max Days Old
<input
type="number"
min="1"
max="14"
class="peer input-text"
value={$maxDaysOld}
onkeydown={(e) => {
if (['-', '+', 'e', 'E'].includes(e.key)) e.preventDefault();
}}
onpaste={(e) => {
e.preventDefault();
$maxDaysOld = e.clipboardData?.getData('text') ?? null;
}}
use:minMaxVal={{
min: 1,
max: 14,
onUpdate: (val) => ($maxDaysOld = val),
}}
/>
</label>
<script lang="ts">
import { Admonition } from '$lib/components';
interface Props {
posts: { author: string; date: Date; message: string; avatar: number }[];
resetFilters: () => void;
}
const { posts, resetFilters }: Props = $props();
</script>
<div class="flex flex-wrap justify-around gap-4 @container">
{#each posts as { author, date, message, avatar } (date)}
<div class="flex flex-[1_0_100%] rounded-card p-4 shadow-2 @md:flex-[1_0_49%] sm:p-8">
<img
class="mr-5 block h-8 w-8 max-w-full align-middle sm:h-16 sm:w-16"
src="https://mighty.tools/mockmind-api/content/abstract/{avatar}.jpg"
alt={author}
/>
<div class="w-full">
<div class="mb-2 flex flex-col justify-between text-gray-11 sm:flex-row">
<h3 class="font-medium">{author}</h3>
<time class="text-xs" datetime={date.toISOString()}>{date.toLocaleString()}</time>
</div>
<p class="text-sm">{message}</p>
</div>
</div>
{:else}
<div class="my-8 mr-auto flex gap-2 items-center">
<Admonition kind="error" title="No messages found." />
<button class="btn btn-hollow" onclick={resetFilters}>Clear All Filters</button>
</div>
{/each}
</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 { assertUnreachable } from '$lib/utils/common';
type GoOpts = { absolute?: string };
const go = async (searchParams: URLSearchParams, opts?: GoOpts) => {
if (browser) {
return goto(`${opts?.absolute ?? ''}?${searchParams}`, {
keepFocus: true,
noScroll: true,
replaceState: true,
}).then(() => true);
} else {
return false;
}
};
/** `false` if the value is not changed, `Promise<false>` if called on the server, and `Promise<true>` if the value and url change */
type Changed = false | Promise<boolean>;
const cloneParams = () => new URLSearchParams(get(page).url.search);
interface SearchParamController {
subscribe: Readable<string | null>['subscribe'];
mutateSearchParams: (a: {
value?: { cleaned?: never; unclean?: string | null } | { cleaned?: string | null; unclean?: never };
mutSearchParams: URLSearchParams;
}) => false | URLSearchParams;
pushStateToParams: (a: { mutSearchParams: URLSearchParams }) => false | URLSearchParams;
set: (unclean: string | null, opts?: GoOpts) => Changed;
toggle: (unclean: string, opts?: GoOpts) => Changed;
}
/**`$store` is equivalent to $page.url.searchParams.get(param) */
export const searchParam = (
name: string,
{ clean, skipInitGoto }: { clean?: (value: string | null) => string | null; skipInitGoto?: true } = {},
): SearchParamController => {
const store = derived(page, ($page) => $page.url.searchParams.get(name));
const serialize = clean || ((v) => v || null);
const init = (): void => {
if (skipInitGoto) return;
set(get(store));
};
/** If mutSearchParams are updated, returns a reference to it. Otherwise returns false. */
const mutateSearchParams: SearchParamController['mutateSearchParams'] = ({ value, mutSearchParams }) => {
const newValue = value?.cleaned ?? serialize(value?.unclean ?? null);
if (mutSearchParams.get(name) === newValue) {
return false;
}
if (!newValue) mutSearchParams.delete(name);
else mutSearchParams.set(name, newValue);
return mutSearchParams;
};
const pushStateToParams: SearchParamController['pushStateToParams'] = ({ mutSearchParams }) => {
return mutateSearchParams({ mutSearchParams, value: { unclean: get(page).url.searchParams.get(name) } });
};
const set: SearchParamController['set'] = (unclean, opts) => {
const newParams = mutateSearchParams({ value: { unclean }, mutSearchParams: cloneParams() });
return newParams ? go(newParams, opts) : false;
};
const toggle: SearchParamController['toggle'] = (unclean, opts) => {
const mutSearchParams = cloneParams();
const oldValue = mutSearchParams.get(name);
const newValue = serialize(unclean);
const newParams = mutateSearchParams({
value: { cleaned: newValue === oldValue ? null : newValue },
mutSearchParams,
});
return newParams ? go(newParams, opts) : false;
};
init();
return {
subscribe: store.subscribe,
mutateSearchParams,
pushStateToParams,
set,
toggle,
};
};
export type SearchParam = SearchParamController;
interface SearchParamsController {
subscribe: Readable<string[]>['subscribe'];
mutateSearchParams: (a: { unclean?: string[]; mutSearchParams: URLSearchParams }) => false | URLSearchParams;
pushStateToParams: (a: { mutSearchParams: URLSearchParams }) => false | URLSearchParams;
set: (values: string[], opts?: GoOpts) => false | Promise<boolean>;
updateOne: (value: string, action: 'add' | 'append' | 'remove' | 'toggle', opts?: GoOpts) => false | Promise<boolean>;
updateMany: (
values: string[],
action: 'add' | 'append' | 'remove' | 'toggle',
opts?: GoOpts,
) => false | Promise<boolean>;
}
/** `$store` is equivalent to $page.url.searchParams.getAll(`${param}`) */
export const searchParams = (
param: string,
{ clean, skipInitGoto }: { clean?: (value: string | null) => string | null; skipInitGoto?: true } = {},
) => {
const store = derived(page, ($page) => $page.url.searchParams.getAll(`${param}`));
const serialize = clean || ((v) => v || null);
const init = (): void => {
if (skipInitGoto) return;
set(get(store));
};
/** If mutSearchParams are updated, returns a reference to it. Otherwise returns false. */
const mutateSearchParams: SearchParamsController['mutateSearchParams'] = ({ unclean, mutSearchParams }) => {
const preMutatedParams = new URLSearchParams(mutSearchParams);
mutSearchParams.delete(param);
unclean?.forEach((v) => {
const cleaned = serialize(v);
if (cleaned) mutSearchParams.append(param, cleaned);
});
if (preMutatedParams.toString() !== mutSearchParams.toString()) return mutSearchParams;
return false;
};
const pushStateToParams: SearchParamsController['pushStateToParams'] = ({ mutSearchParams }) => {
return mutateSearchParams({ mutSearchParams, unclean: get(page).url.searchParams.getAll(param) });
};
const set: SearchParamsController['set'] = (unclean, opts) => {
const newParams = mutateSearchParams({ unclean, mutSearchParams: cloneParams() });
return newParams ? go(newParams, opts) : false;
};
const updateOne: SearchParamsController['updateOne'] = (unclean, action, opts) => {
const value = serialize(unclean);
if (value === null) return false;
const mutSearchParams = cloneParams();
if (action === 'append') {
mutSearchParams.append(param, value);
return go(mutSearchParams, opts);
}
const paramValues = mutSearchParams.getAll(param);
const index = paramValues.findIndex((p) => p === value);
if (index === -1) {
if (action === 'remove') return false;
mutSearchParams.append(param, value);
return go(mutSearchParams, opts);
} else {
if (action === 'add') return false;
paramValues.splice(index, 1);
mutSearchParams.delete(param);
paramValues.forEach((p) => mutSearchParams.append(param, p));
return go(mutSearchParams, opts);
}
};
const updateMany: SearchParamsController['updateMany'] = (unclean, action, opts) => {
const values = unclean.map(serialize).filter((v) => v !== null) as string[];
if (!values.length) return false;
const mutSearchParams = cloneParams();
let changed = false;
switch (action) {
case 'append': {
changed = true;
for (const value of values) {
mutSearchParams.append(param, value);
}
break;
}
case 'add': {
const paramSet = new Set(mutSearchParams.getAll(param));
for (const value of values) {
if (!paramSet.has(value)) {
changed = true;
mutSearchParams.append(param, value);
}
}
break;
}
case 'remove': {
const paramValues = mutSearchParams.getAll(param);
for (const value of values) {
const index = paramValues.findIndex((p) => p === value);
if (index !== -1) {
changed = true;
paramValues.splice(index, 1);
}
}
if (changed) {
mutSearchParams.delete(param);
paramValues.forEach((p) => mutSearchParams.append(param, p));
}
break;
}
case 'toggle': {
const paramValues = mutSearchParams.getAll(param);
changed = true;
for (const value of values) {
const index = paramValues.findIndex((p) => p === value);
if (index !== -1) {
paramValues.splice(index, 1);
} else {
paramValues.push(value);
}
}
mutSearchParams.delete(param);
paramValues.forEach((p) => mutSearchParams.append(param, p));
break;
}
default: {
assertUnreachable(action);
}
}
if (changed) return go(mutSearchParams, opts);
return false;
};
init();
return {
subscribe: store.subscribe,
mutateSearchParams,
pushStateToParams,
set,
updateOne,
updateMany,
};
};
export type SearchParams = SearchParamsController;
export const initMany = (params: Array<SearchParamController | SearchParamsController>): Changed => {
const mutSearchParams = cloneParams();
const changed = params.reduce<boolean>((acc, curr) => {
const changed = !!curr.pushStateToParams({ mutSearchParams });
return acc || changed;
}, false);
if (!changed) return false;
return go(mutSearchParams);
};
const createRandomTimeBetween = (h1: number, h2: number) => {
const d1 = new Date(Date.now() - 1000 * 60 * 60 * h1);
const d2 = new Date(Date.now() - 1000 * 60 * 60 * h2);
const diff = d1.getTime() - d2.getTime();
const newDiff = Math.random() * diff;
const date = new Date(d2.getTime() + newDiff);
return date;
};
export const loadDummyData = () => {
const a1 = { author: 'Olivia', avatar: 50 };
const a2 = { author: 'Xavier', avatar: 16 };
const a3 = { author: 'Isabella', avatar: 38 };
const a4 = { author: 'Elijah', avatar: 4 };
const messages = [
{
...a1,
message: 'The mysterious old house creaked in the eerie silence of the night.',
date: createRandomTimeBetween(0, 1),
},
{
...a4,
message: 'Sunflowers nodded in agreement as a gentle breeze whispered through the field.',
date: createRandomTimeBetween(3, 4),
},
{
...a3,
message: 'The rhythmic sound of raindrops on the roof played a soothing lullaby.',
date: createRandomTimeBetween(8, 10),
},
{
...a3,
message: 'A mischievous squirrel darted across the park, stealing glances from curious onlookers.',
date: createRandomTimeBetween(26, 30),
},
{
...a1,
message: 'The antique pocket watch ticked with precision, a relic from a bygone era.',
date: createRandomTimeBetween(35, 40),
},
{
...a4,
message: 'A kaleidoscope of colors painted the sky as the sun bid farewell to the day.',
date: createRandomTimeBetween(24 * 3, 24 * 5),
},
{
...a2,
message: 'Laughter echoed through the bustling marketplace, creating a lively atmosphere.',
date: createRandomTimeBetween(24 * 5, 24 * 7),
},
{
...a4,
message: 'The scent of freshly baked bread wafted through the air, enticing hungry passersby.',
date: createRandomTimeBetween(24 * 8, 24 * 9),
},
{
...a1,
message: 'A lone wolf howled in the distance, its mournful cry carrying through the night.',
date: createRandomTimeBetween(24 * 9, 24 * 10),
},
{
...a3,
message: 'The magician waved his wand, and a burst of sparks filled the darkened room.',
date: createRandomTimeBetween(24 * 10, 24 * 11),
},
{
...a2,
message: 'Time seemed to stand still as the dancer twirled gracefully on the empty stage.',
date: createRandomTimeBetween(24 * 11, 24 * 12),
},
{
...a2,
message: 'The old bookstore held the musty fragrance of aging paper, a treasure trove for book lovers.',
date: createRandomTimeBetween(24 * 12, 24 * 13),
},
];
return {
messages,
authors: [a1, a2, a3, a4].map((a) => a.author),
};
};
export type DummyData = ReturnType<typeof loadDummyData>;
This is part 1 of 2 in the URL State Controller series.
URL State Controller
Part 1 of 2 in the URL State Controller series.
- 01. Simple URL State Controller
- 02. Generic URL State Controller
Storing state in the url is a useful pattern which allows the state to be shared or recovered through a link. This pattern might be used for a share button, a bookmark, an internal link to some initial state, or to restore state after completing another flow.
In this article, we'll create controllers for the search param state that we can use just as easily as regular Svelte stores. We'll put them to use by storing the filter state of posts in the URL as in the demo above.
Thinking through the API
Odds are, we'll likely want to separate the API for single and multi-value search params. The stored values should be
identical to URLSearchParams
. For single values, that's string | null
. For multi-values,
that's string[]
.
Param Count | Equivalent $page fn | Example |
---|---|---|
1 | $page.url.searchParams.get(param) | ?content=foo |
2+ | $page.url.searchParams.getAll(param) | ?authors=Xavier&authors=Olivia |
const content = searchParam('content');
const authors = searchParams('authors');
We know our controller will need to include a reactive store with the current value of the desired search param, so let's create that first.
import { page } from '$app/stores';
import { derived, get } from 'svelte/store';
const searchParam = (param: string) => {
const store = derived(page, ($page) => $page.url.searchParams.get(param));
return {
subscribe: store.subscribe,
}
}
const searchParams = (param: string) => {
const store = derived(page, ($page) => $page.url.searchParams.getAll(param));
return {
subscribe: store.subscribe,
}
}
Single Value
API
Fulfilling the Store Contract
Our single value controller should be as simple to use as a regular store.
const store = writable<string | null>(null)
$store = 'hello';
const paramStore = searchParam('param'); // $paramStore is string | null
$paramStore = 'hello'; // navigates to ?param=hello
For $paramStore = 'hello'
to work, we need to fulfil Svelte's Store
interface with a set
method. paramStore.set('hello')
should add ?param=hello
to the url, causing
our derived store to update and notify all subscribers.
interface SearchParamController {
subscribe: Readable<string | null>['subscribe'];
set: (value: string | null) => void;
}
Convenience Methods
Ideally we'd have a convenience function to toggle it too. paramStore.toggle('hello')
should set the search to ?param=hello
if it's not already set,
and null
if it is.
interface SearchParamController {
subscribe: Readable<string | null>['subscribe'];
set: (value: string | null) => void;
toggle: (value: string) => void;
}
Great! But what if the url state is set to ?param=foo
and we call paramStore.toggle('bar')
? Should it be ?param=bar
or null
? Most likely, we'll
want to switch it to 'bar'
and then another .toggle('bar')
call will switch it to null
.
Multiple Params
We may want to update multiple params before actually navigating to the new page. This is especially important if we
need to clean the url (we'll show an example shortly!). For that reason, let's make a mutateSearchParams
function. set
will be a small wrapper that calls it and navigates. pushStateToParams
will just
update the state but not navigate.
interface SearchParamController {
subscribe: Readable<string | null>['subscribe'];
mutateSearchParams: (a: { newValue?: string | null; mutSearchParams: URLSearchParams }) => false | URLSearchParams;
pushStateToParams: (a: { mutSearchParams: URLSearchParams }) => false | URLSearchParams;
set: (value: string | null) => void;
toggle: (value: string) => void;
}
Result
We'll probably want to know whether the set
or toggle
functions were successful, and be able
to await their navigation if so.
/** `false` if the value is not changed, `Promise<false>` if called on the server, and `Promise<true>` if the value and url change */
type Changed = false | Promise<boolean>;
interface SearchParamController {
subscribe: Readable<string | null>['subscribe'];
mutateSearchParams: (a: { newValue?: string | null; mutSearchParams: URLSearchParams }) => false | URLSearchParams;
pushStateToParams: (a: { mutSearchParams: URLSearchParams }) => false | URLSearchParams
set: (value: string | null) => Changed;
toggle: (value: string) => Changed;
}
Go Options
And lastly, if the input (for example a search bar) is in a layout, we may or may not need to change the url.pathname
and not just the url.search
. We'll add some go options to our set
and toggle
functions.
type GoOpts = { absolute?: string };
/** `false` if the value is not changed, `Promise<false>` if called on the server, and `Promise<true>` if the value and url change */
type Changed = false | Promise<boolean>;
interface SearchParamController {
subscribe: Readable<string | null>['subscribe'];
mutateSearchParams: (a: { newValue?: string | null; mutSearchParams: URLSearchParams }) => false | URLSearchParams;
pushStateToParams: (a: { mutSearchParams: URLSearchParams }) => false | URLSearchParams
set: (value: string | null, opts?: GoOpts) => Changed;
toggle: (value: string, opts?: GoOpts) => Changed;
}
Implementation
We've got a solid API, so let's write the implementation.
The main mechanism will be to navigate with goto
and let our derived store handle the state changes.
We'll do this by getting the search params of the current page, adding or deleting the new param, and using goto
to update the url.
Helpers
goto
takes a few options that we'll need to turn on for every call, so let's make a wrapper called go
. replaceState: true
will allow the user to go back to the previous page with the back
button, skipping all the previous state changes. keepFocus
and noScroll
will also need to be
switched on. Finally, we'll add in our absolute
option.
type GoOpts = { absolute?: string };
export const go = async (searchParams: URLSearchParams, opts?: GoOpts) => {
if (browser) {
return goto(`${opts?.absolute ?? ''}?${searchParams}`, {
keepFocus: true,
noScroll: true,
replaceState: true,
}).then(() => true);
} else {
return false;
}
};
Notice we're taking in searchParams
, but we know that the $page
store will not update if we
simply mutate the searchParams
. It must be a new reference.
Mutable reference to $page.url.searchParams
New object created from $page.url.searchParams
// const searchParams = get(page).url.search; // this will not update $page when goto is called
const searchParams = new URLSearchParams(get(page).url.search); // will update $page
searchParams.set('foo', 'bar');
goto(searchParams.toString())
So let's make a cloneParams
helper.
const cloneParams = () => new URLSearchParams(get(page).url.search);
searchParam
One possible implementation:
/**`$store` is equivalent to $page.url.searchParams.get(param) */
export const searchParam = (name: string): SearchParamController => {
const store = derived(page, ($page) => $page.url.searchParams.get(name));
const mutateSearchParams: SearchParamController['mutateSearchParams'] = ({ newValue, mutSearchParams }) => {
if (mutSearchParams.get(name) === newValue) {
return false;
}
if (!newValue) mutSearchParams.delete(name);
else mutSearchParams.set(name, newValue);
return mutSearchParams;
};
const pushStateToParams = ({ mutSearchParams }: { mutSearchParams: URLSearchParams }): false | URLSearchParams => {
return mutateSearchParams({ mutSearchParams, newValue: get(page).url.searchParams.get(name) });
};
const set: SearchParamController['set'] = (newValue, opts) => {
const newParams = mutateSearchParams({ newValue, mutSearchParams: cloneParams() });
return newParams ? go(newParams, opts) : false;
};
const toggle: SearchParamController['toggle'] = (newValue, opts) => {
const mutSearchParams = cloneParams();
const oldValue = mutSearchParams.get(name);
const newParams = mutateSearchParams({ newValue: newValue === oldValue ? null : newValue, mutSearchParams });
return newParams ? go(newParams, opts) : false;
};
return {
subscribe: store.subscribe,
mutateSearchParams,
pushStateToParams,
set,
toggle,
};
};
Adding Validation
We now have a working implementation, but there's something we could add: bounds. This implementation expects the
consumer to provide any cleaning logic before calling the methods. Even if they did so, anyone could manipulate the
url directly and the values would be unclean. Let's fix that by adding an optional callback to clean the value. The mutateSearchParams
method will be able to accept either a cleaned or unclean value and call the clean function
when necessary.
interface SearchParamController {
subscribe: Readable<string | null>['subscribe'];
mutateSearchParams: (a: {
value?: { cleaned?: never; unclean?: string | null } | { cleaned?: string | null; unclean?: never };
mutSearchParams: URLSearchParams;
}) => false | URLSearchParams;
pushStateToParams: (a: { mutSearchParams: URLSearchParams }) => false | URLSearchParams;
set: (unclean: string | null, opts?: GoOpts) => Changed;
toggle: (unclean: string, opts?: GoOpts) => Changed;
}
As our initial value may also be unclean (for example if the user navigates to ?param=unclean
directly), we'll also add an init
function that invokes the clean function.
The final consideration before we implement the final version of our searchParam
controller is that we won't always be
able to handle the cleaning logic on initialization. If a user goes to ?param1=unclean¶m2=also_unclean
directly, the first init function would clean
and goto
?param1=clean¶m2=also_unclean
, but the second goto
would run concurrently and be lost. Because of this, we'll make sure to have an option to skip the
initial goto
call, so the consumer can run it after all the controllers have initialized.
First, let's implement it and then we'll have a look at what the cleaning looks like in practice.
One possible implementation:
export const searchParam = (
name: string,
{ clean, skipInitGoto }: { clean?: (value: string | null) => string | null; skipInitGoto?: true } = {},
): SearchParamController => {
const store = derived(page, ($page) => $page.url.searchParams.get(name));
const serialize = clean || ((v) => v || null);
const init = (): void => {
if (skipInitGoto) return;
set(get(store));
};
/** If mutSearchParams are updated, returns a reference to it. Otherwise returns false. */
const mutateSearchParams: SearchParamController['mutateSearchParams'] = ({ value, mutSearchParams }) => {
const newValue = value?.cleaned ?? serialize(value?.unclean ?? null);
if (mutSearchParams.get(name) === newValue) {
return false;
}
if (!newValue) mutSearchParams.delete(name);
else mutSearchParams.set(name, newValue);
return mutSearchParams;
};
const pushStateToParams: SearchParamController['pushStateToParams'] = ({ mutSearchParams }) => {
return mutateSearchParams({ mutSearchParams, value: { unclean: get(page).url.searchParams.get(name) } });
};
const set: SearchParamController['set'] = (unclean, opts) => {
const newParams = mutateSearchParams({ value: { unclean }, mutSearchParams: cloneParams() });
return newParams ? go(newParams, opts) : false;
};
const toggle: SearchParamController['toggle'] = (unclean, opts) => {
const mutSearchParams = cloneParams();
const oldValue = mutSearchParams.get(name);
const newValue = serialize(unclean);
const newParams = mutateSearchParams({
value: { cleaned: newValue === oldValue ? null : newValue },
mutSearchParams,
});
return newParams ? go(newParams, opts) : false;
};
init();
return {
subscribe: store.subscribe,
mutateSearchParams,
pushStateToParams,
set,
toggle,
};
};
Example Usage
Now let's use it! Try it out below.
<script lang="ts">
import { searchParam } from '$lib/stores';
const singleSearchParam = searchParam('single-search-param');
const easter = searchParam('easter', { clean: (v) => (v === 'egg' ? '🐰' : v) });
</script>
<input type="text" bind:value={$singleSearchParam} />
<input type="text" bind:value={$easter} />
As mentioned before, we won't be able to rely on init
when using multiple validation params on a single
page. If the user went to ?easter=easter&egg=egg
when our code looked like this:
const easter = searchParam('easter', { clean: (v) => (v === 'easter' ? '🐰' : v) });
const egg = searchParam('egg', { clean: (v) => (v === 'egg' ? '🐰' : v) });
The user would end up at ?easter=🐰&egg=egg
.
But because we have a separate pushStateToParams
method and a skipInitGoto
option, we can handle
this with a simple standalone helper.
export const initMany = (params: Array<SearchParamController | SearchParamsController>): Changed => {
const mutSearchParams = cloneParams();
const changed = params.reduce<boolean>((acc, curr) => {
const changed = !!curr.pushStateToParams({ mutSearchParams });
return acc || changed;
}, false);
if (!changed) return false;
return go(mutSearchParams);
};
const easter = searchParam('easter', { clean: (v) => (v === 'easter' ? '🐰' : v), skipInitGoto: true });
const egg = searchParam('egg', { clean: (v) => (v === 'egg' ? '🐰' : v), skipInitGoto: true });
initMany([easter, egg]);
Now our user will be redirected from ?easter=easter&egg=egg
to ?easter=🐰&egg=🐰
immediately as desired.
Multi Value
API
Our multi-value controller's helper functions will be a little more detailed.
If our tags are ?tags=foo&tags=bar
, we'll need to be able to add, append, remove, and toggle a value.
Action | Example | Result |
---|---|---|
add | tags.updateOne('baz', 'add') | ?tags=foo&tags=bar&tags=baz |
remove | tags.updateOne('bar', 'remove') | ?tags=foo |
append | tags.updateOne('bar', 'append') | ?tags=foo&tags=bar&tags=bar |
toggle | tags.updateOne('bar', 'toggle') | ?tags=foo |
Likewise, we should be able to do the same thing for multiple values.
It'll also be nice to have a set
method so we can use the syntactic $store = value
syntax.
interface SearchParamsController {
subscribe: SearchParams['subscribe'];
mutateSearchParams: (a: { unclean?: string[]; mutSearchParams: URLSearchParams }) => false | URLSearchParams;
set: (values: string[]) => false | Promise<boolean>;
updateOne: (value: string, action: 'add' | 'append' | 'remove' | 'toggle') => false | Promise<boolean>;
updateMany: (values: string[], action: 'add' | 'append' | 'remove' | 'toggle') => false | Promise<boolean>;
}
Implementation
The ideas are the same, so we'll skip straight to the code. Here's one possible implementation.
/** `$store` is equivalent to $page.url.searchParams.getAll(`${param}`) */
/** `$store` is equivalent to $page.url.searchParams.getAll(`${param}`) */
export const searchParams = (
param: string,
{ clean, skipInitGoto }: { clean?: (value: string | null) => string | null; skipInitGoto?: true } = {},
) => {
const store = derived(page, ($page) => $page.url.searchParams.getAll(`${param}`));
const serialize = clean || ((v) => v || null);
const init = (): void => {
if (skipInitGoto) return;
set(get(store));
};
/** If mutSearchParams are updated, returns a reference to it. Otherwise returns false. */
const mutateSearchParams: SearchParamsController['mutateSearchParams'] = ({ unclean, mutSearchParams }) => {
const preMutatedParams = new URLSearchParams(mutSearchParams);
mutSearchParams.delete(param);
unclean?.forEach((v) => {
const cleaned = serialize(v);
if (cleaned) mutSearchParams.append(param, cleaned);
});
if (preMutatedParams.toString() !== mutSearchParams.toString()) return mutSearchParams;
return false;
};
const pushStateToParams: SearchParamsController['pushStateToParams'] = ({ mutSearchParams }) => {
return mutateSearchParams({ mutSearchParams, unclean: get(page).url.searchParams.getAll(param) });
};
const set: SearchParamsController['set'] = (unclean, opts) => {
const newParams = mutateSearchParams({ unclean, mutSearchParams: cloneParams() });
return newParams ? go(newParams, opts) : false;
};
const updateOne: SearchParamsController['updateOne'] = (unclean, action, opts) => {
const value = serialize(unclean);
if (value === null) return false;
const mutSearchParams = cloneParams();
if (action === 'append') {
mutSearchParams.append(param, value);
return go(mutSearchParams, opts);
}
const paramValues = mutSearchParams.getAll(param);
const index = paramValues.findIndex((p) => p === value);
if (index === -1) {
if (action === 'remove') return false;
mutSearchParams.append(param, value);
return go(mutSearchParams, opts);
} else {
if (action === 'add') return false;
paramValues.splice(index, 1);
mutSearchParams.delete(param);
paramValues.forEach((p) => mutSearchParams.append(param, p));
return go(mutSearchParams, opts);
}
};
const updateMany: SearchParamsController['updateMany'] = (unclean, action, opts) => {
const values = unclean.map(serialize).filter((v) => v !== null) as string[];
if (!values.length) return false;
const mutSearchParams = cloneParams();
let changed = false;
switch (action) {
case 'append': {
changed = true;
for (const value of values) {
mutSearchParams.append(param, value);
}
break;
}
case 'add': {
const paramSet = new Set(mutSearchParams.getAll(param));
for (const value of values) {
if (!paramSet.has(value)) {
changed = true;
mutSearchParams.append(param, value);
}
}
break;
}
case 'remove': {
const paramValues = mutSearchParams.getAll(param);
for (const value of values) {
const index = paramValues.findIndex((p) => p === value);
if (index !== -1) {
changed = true;
paramValues.splice(index, 1);
}
}
if (changed) {
mutSearchParams.delete(param);
paramValues.forEach((p) => mutSearchParams.append(param, p));
}
break;
}
case 'toggle': {
const paramValues = mutSearchParams.getAll(param);
changed = true;
for (const value of values) {
const index = paramValues.findIndex((p) => p === value);
if (index !== -1) {
paramValues.splice(index, 1);
} else {
paramValues.push(value);
}
}
mutSearchParams.delete(param);
paramValues.forEach((p) => mutSearchParams.append(param, p));
break;
}
default: {
assertUnreachable(action);
}
}
if (changed) return go(mutSearchParams, opts);
return false;
};
init();
return {
subscribe: store.subscribe,
mutateSearchParams,
pushStateToParams,
set,
updateOne,
updateMany,
};
};
Conclusion
And that's it! We've created a simple wrapper around a derived store that syncs application state with the URL. This is everything we need to write the demo at the top of the article. Full code here.
If you're following along closely, you've noticed we made a design decision early on that has knock-on effects. We
decided to use $page
as the single source of truth for our store value. This decision makes sense when
the highest priority is syncing the url and store state. However, it does have some limitations. Namely, there is a
short duration between store initialization and the first goto
where the state is unclean. Also, we can't
use generics to type our store. We might instead want to accept that the url and store state will be out of sync during
construction as a tradeoff to receive an infallible, generic store.
In part 2, we'll make another variation which will have the following benefits:
- The store can never be "unclean".
- The store is generic, so we can logically group and clean parameters together (for example min/max values).
- The store can be injected into other code (like melt-ui).
Have any questions or comments? Share it in the GitHub discussions! Thanks for reading and see you in the next one!
This is part 1 of 2 in the URL State Controller series.
URL State Controller
Part 1 of 2 in the URL State Controller series.
- 01. Simple URL State Controller
- 02. Generic URL State Controller