Generic URL State Controller

  • url
  • state management
  • generics
  • context api
  • TypeScript

9 min read 1829 words

$page.url.search:

Store

Query:
Available:
Price: {"min":null,"max":null}
Sort By: {"label":"relevance","value":{"paramName":null,"sortKey":"RELEVANCE","reverse":false}}
Query
Price

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

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&params=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 a derived store without a deserialize function, we implicitly chose that it should be unclean.

  • The state's type is string | null or string[].

    • We don't want to trap ourselves with JS type coercion by writing if ($availability) and always receiving true because "false" evaluates truthy. Booleans should be encoded as boolean.
    • 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.
  • 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 a load function that referenced url, and the searchParam 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.

Note
We'll consider objects that will be presented to the user with multiple inputs – think { min, max } price – later.
paramGeneric.ts
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.

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

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

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

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

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

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

paramGeneric.ts
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:

paramGeneric.ts

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.

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

paramsGeneric.ts

Implementation

And a possible implementation:

paramsGeneric.ts

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!

Published

Next Article


Image Cropper And Uploader

Select an image, crop it, upload it to an AWS S3 Bucket with a progress indicator, moderate it with Rekognition, save it to the DB, and serve it via AWS Cloudfront.
Updated: August 16, 2024

Have a suggestion? File an issue.