Simple URL State Controller

  • url
  • state management

12 min read 2672 words

Simple Url State Controller Demo

$page.url.search:

Filters

Authors
Olivia

Olivia

The mysterious old house creaked in the eerie silence of the night.

Elijah

Elijah

Sunflowers nodded in agreement as a gentle breeze whispered through the field.

Isabella

Isabella

The rhythmic sound of raindrops on the roof played a soothing lullaby.

Isabella

Isabella

A mischievous squirrel darted across the park, stealing glances from curious onlookers.

Olivia

Olivia

The antique pocket watch ticked with precision, a relic from a bygone era.

Elijah

Elijah

A kaleidoscope of colors painted the sky as the sun bid farewell to the day.

Xavier

Xavier

Laughter echoed through the bustling marketplace, creating a lively atmosphere.

Elijah

Elijah

The scent of freshly baked bread wafted through the air, enticing hungry passersby.

Olivia

Olivia

A lone wolf howled in the distance, its mournful cry carrying through the night.

Isabella

Isabella

The magician waved his wand, and a burst of sparks filled the darkened room.

Xavier

Xavier

Time seemed to stand still as the dancer twirled gracefully on the empty stage.

Xavier

Xavier

The old bookstore held the musty fragrance of aging paper, a treasure trove for book lovers.

This is part 1 of 2 in the URL State Controller series.

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 CountEquivalent $page fnExample
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.

.set(value):
.toggle(value):

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.

Important
Svelte 4 subscriptions are notified on assignment changes, not internal mutations.

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&param2=also_unclean directly, the first init function would clean and goto ?param1=clean&param2=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:

searchParam.ts
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.

ActionExampleResult
addtags.updateOne('baz', 'add')?tags=foo&tags=bar&tags=baz
removetags.updateOne('bar', 'remove')?tags=foo
appendtags.updateOne('bar', 'append')?tags=foo&tags=bar&tags=bar
toggletags.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.

searchParams.ts
/** `$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!

Published

Previous Article


Blog with Preprocessors

Use preprocessors to highlight code blocks, render math, and write Markdown directly in Svelte components – all without disrupting other tooling.
Updated: August 15, 2024

Next Article


Theme Controller

A theme controller that uses CSS variables to control light/dark mode with multiple themes, saves user preference to Cookies, and avoids flashes of unstyled content.
Updated: August 13, 2024

Have a suggestion? File an issue.