
Developer, administrator
Crafting reactive web storage with Svelte createSubscriber API
Following the spirit of fully utilizing (and abusing to the best of my ability) Svelte 5 reactivity,
as discussed in the article "Reactive Wrapper for WXT Extension Storage with Svelte's createSubscriber ", I want to share a solution that wraps WebStorage (localStorage
and sessionStorage
) to provide a nicer development experience in Svelte projects.
Copy the following code into your project...
import { createSubscriber } from 'svelte/reactivity';
// https://hackernoon.com/mastering-type-safe-json-serialization-in-typescript
type JSONPrimitive = string | number | boolean | null | undefined;
type JSONValue =
| JSONPrimitive
| JSONValue[]
| {
[key: string]: JSONValue;
};
interface ReactiveStorageConfig {
prefix: string;
storage: 'local' | 'session';
}
interface ReactiveStorageItemConfig extends ReactiveStorageConfig {
init: JSONValue;
}
interface ReactiveStorageItem extends Omit<ReactiveStorageItemConfig, 'storage'> {
key: string;
storage: Storage | null;
value: JSONValue;
update?: Parameters<Parameters<typeof createSubscriber>[0]>[0];
}
type ReactiveStorage<ValueMap extends Record<string, JSONValue>> = ValueMap & {
/**
* set each item back to initial value (if any)
**/
reset: () => void;
};
export function createReactiveStorage<ValueMap extends Record<string, JSONValue>>(
values: (keyof ValueMap | [key: keyof ValueMap, config: Partial<ReactiveStorageItemConfig>])[],
config: Partial<ReactiveStorageConfig> = {},
): ReactiveStorage<ValueMap> {
const globalConfig: ReactiveStorageConfig = {
prefix: '',
storage: 'local',
...config,
};
// resolve config for each item
const items = values.reduce(
(acc, value) => {
let key: string;
let config: ReactiveStorageItemConfig;
if (Array.isArray(value)) {
config = { init: null, ...globalConfig, ...value[1] };
key = value[0].toString();
} else {
config = { ...globalConfig, init: null };
key = value.toString();
}
let storage: Storage | null = null;
if (config.storage === 'local' && 'localStorage' in globalThis) {
storage = localStorage;
} else if (config.storage === 'session' && 'sessionStorage' in globalThis) {
storage = sessionStorage;
}
const keyWithPrefix = config.prefix + key;
acc[keyWithPrefix] = {
...config,
key,
storage,
value: config.init,
update: undefined, // will be set later
};
return acc;
},
{} as Record<string, ReactiveStorageItem>,
);
const reactive = {} as ReactiveStorage<ValueMap>;
for (const [keyWithPrefix, item] of Object.entries(items)) {
const { storage, init, key } = item;
const subscribe = createSubscriber((update) => {
items[keyWithPrefix].update = update;
});
// initialize
const json = storage?.getItem(keyWithPrefix);
if (json) {
item.value = JSON.parse(json) as JSONValue;
} else if (init !== null && init !== undefined) {
storage?.setItem(keyWithPrefix, JSON.stringify(init));
}
// proxy via getter/setter
Object.defineProperty(reactive, key, {
enumerable: true,
get() {
subscribe();
return item.value;
},
set(newValue: JSONValue) {
item.value = newValue;
if (!storage) return;
if (newValue === null) {
storage.removeItem(keyWithPrefix);
} else {
storage.setItem(keyWithPrefix, JSON.stringify(newValue));
}
item.update?.();
},
});
}
// implement reset method
Object.defineProperty(reactive, 'reset', {
enumerable: false,
writable: false,
configurable: false,
value: () => {
for (const item of Object.values(items)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(reactive as any)[item.key] = item.init;
}
},
});
// listen for storage changes from other documents
if ('addEventListener' in globalThis) {
globalThis.addEventListener('storage', (event) => {
const { key: keyWithPrefix, storageArea, newValue } = event;
if (!keyWithPrefix || !storageArea) return;
const item = items[keyWithPrefix];
if (!item || item.storage !== storageArea) return;
item.value = JSON.parse(newValue || 'null') as JSONValue;
item.update?.();
});
}
return reactive;
}
...and do something like this:
import { createReactiveStorage } from './lib';
type StorageValue = {
str: string | null;
num: number | null;
bool: boolean | null;
obj: {
str: string | null;
num: number | null;
bool: boolean | null;
};
};
export const storage = createReactiveStorage<StorageValue>(
[['str', { storage: 'session' }], ['num', { init: 0 }], ['bool', { prefix: 'special:' }], 'obj'],
{
prefix: 'app:',
storage: 'local',
},
);
storage.num
, storage.str
, etc. are now reactive 🎉!
Please have a look at this Reddit discussion and the proposed solution by Joy of Code, as well as this test code by Rich Harris. These solutions are all useful, but they do not fully meet my requirements (but may do yours). Plus, with the relatively new createSubscriber API, I want to revisit this problem space.
The solution presented here is designed to address the following objectives:
JSON.stringify
and JSON.parse
when storing and retrieving data from WebStorage,
since localStorage
and sessionStorage
currently only support storing values as strings,You may try out my solution in this section (or via this Svelte
REPL).
To start out, have a look at the following visual representation for the designated configuration of the storage
object:
The code needed to create the storage
object is listed in the storage.ts
code block in the "Feed
me code!" section. Next, use the following playground and try changing the values of the
items:
Expand the following code block if you want to see the actual source code of the playground above:
<script lang="ts">
import { storage } from './storage';
function increment(step: number = 1) {
if (storage.num === null) {
storage.num = 0;
} else {
storage.num += step;
}
}
function randomObj() {
storage.obj = {
str: Math.random().toString(36).substring(2, 15),
num: Math.floor(Math.random() * 100),
bool: Math.random() > 0.5,
};
}
</script>
<fieldset class="not-prose overflow-auto border border-outline p-4 max-w-full min-w-0">
<legend>Interactive Playground</legend>
<table class="border-collapse c-text-body-sm w-full">
<thead>
<tr class="bg-surface-subtle">
<th class="w-20" scope="col">Key</th>
<th scope="col">
<div class="flex items-center justify-between gap-2">
<span class="flex-1">Current Value</span>
<button class="c-btn py-0 px-2" onclick={storage.reset}>
<span>Reset all</span>
</button>
</div>
</th>
<th class="w-40" scope="col">Action</th>
</tr>
</thead>
<tbody>
<!-- str -->
<tr>
<th scope="row">str</th>
<td>"{storage.str}"</td>
<td>
<input class="c-text-input py-1 px-2" type="text" placeholder="Type something..." bind:value={storage.str} />
</td>
</tr>
<!-- num -->
<tr>
<th scope="row">num</th>
<td>{storage.num}</td>
<td>
<button class="c-btn c-text-body-sm py-1 px-2" onclick={() => increment(1)}>
<span>Increment</span>
</button>
<button class="c-btn c-text-body-sm py-1 px-2" onclick={() => increment(-1)}>
<span>Decrement</span>
</button>
</td>
</tr>
<!-- bool -->
<tr>
<th scope="row">bool</th>
<td>{storage.bool}</td>
<td>
<label class="flex items-center gap-2 cursor-pointer">
<input class="c-checkbox" type="checkbox" bind:checked={storage.bool} />
<span>Toggle</span>
</label>
</td>
</tr>
<!-- obj -->
<tr>
<th scope="row">obj</th>
<td>{JSON.stringify(storage.obj)}</td>
<td>
<button class="c-btn c-text-body-sm py-1 px-2" onclick={randomObj}>
<span>Randomize</span>
</button>
</td>
</tr>
</tbody>
</table>
</fieldset>
<style lang="postcss">
td, th {
padding: 0.5rem 1rem;
border: 1px solid var(--color-outline);
vertical-align: middle;
}
</style>
It's hard to say how to strike a balance when designing an API that is both simple and performant
in architecture, while also providing the most ergonomic and friendly API experience. In this
particular case, one of my requirements is that the created storage
should be as "normal" as a
plain old Javascript object is, which practically means its properties can referenced directly
(e.g. storage.field
) without any intermediate layer (e.g. storage.field.current
):
<input type="text" bind:value={storage.str}>
Next, I want to be able to initialize multiple storage items in a single centralized storage
object,
rather than having one object per item:
const str = new ReactiveStorageItem('str');
const bool = new ReactiveStorageItem('bool');
const storage = new ReactiveStorage(['str', 'bool']);
storage.str; storage.bool;
Plus, I want to utilize as much as possible Typescript's support for type checking, but without depending on too much magical generics & inference:
type StorageValue = {
str: string | null;
num: number | null;
/* ... */
};
const storage = new ReactiveStorage<StorageValue>({ /* ... */ })
storage.str; // <-- string
stroage.num; // <-- number
I have a tendency to wrap this kind of code snippets into libraries for easier grab-and-go resuability — similar to the libraries I maintain at the @svelte-put collection. However, I haven't used this solution enough to evaluate its practicality and stability.
There are quite a few issues to consider when packaging a solution like this into a library. A few examples:
JSON.parse
fails during data
retrieval,onchange
mechanism, such as through some CustomEvent,If you think this is a useful solution and should be packaged into a library, please let me know!
To avoid being too verbose, I didn't go into much details for the source code in this article. Any feedback or suggestion is well appreciated. Should you want to have a chat, please find me at vnphanquang on Bluesky or through the Svelte Vietnam Discord.
In any case, I hope this solution is useful to you. Thank you for reading!
Found a typo or need to correct something? Edit this blog post content on Github
Managing CSS icons with Iconify & Tailwind V4, and SVG icons with @svelte-put/inline-svg in Vite & Svelte apps
The Svelte Vietnam Blog Newsletter
Subscribe to receive notification for new blog post from Svelte Vietnam
Quick share for a use case of Svelte's reactivity in building web extensions with wxt.dev and the extension storage API
Edit this page on Github sveltevietnam.dev is an open source project and welcomes your contributions. Thank you!