Svelte Vietnam (Go to home page)
Svelte Vietnam (Go to home page)

Reactive Local/Session Storage in Svelte 5

Crafting reactive web storage with Svelte createSubscriber API

Manual translation You are reading a manual translation of the blog post.

5 min read, ~ 1000 words

June 2025

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.

Feed me code!

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 🎉!

Prior Arts

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.

Objectives

The solution presented here is designed to address the following objectives:

  1. Provide reactivity via an abstraction as minimal as possible for WebStorage items,
  2. Automate JSON.stringify and JSON.parse when storing and retrieving data from WebStorage, since localStorage and sessionStorage currently only support storing values as strings,
  3. Just works™. In concrete terms, that means minimal boilerplate and doesn't crash on server side.

Experimentation

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:

Configuration
KeyPrefixTypeStorage AreaInitial Value
strapp:StringSessionStoragenull
numapp:NumberLocalStorage0
boolspecial:BooleanLocalStoragenull
objapp:ObjectLocalStoragenull

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:

Interactive Playground
Key
Current Value
Action
str""
num0
bool
objnull

Try having two different tabs/windows open and observe the values being synchronized in all documents.

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>

API Design

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

Should I make this into a library?

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:

  • what to do when encountering a runtime error, such as when JSON.parse fails during data retrieval,
  • should I provide an onchange mechanism, such as through some CustomEvent,
  • how do I test this library code.

If you think this is a useful solution and should be packaged into a library, please let me know!

Closing

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

Latest posts

Comments

Loading comments from Bluesky (needs javascript). Please hang tight...

Bluesky

Give kudos, and join the conversation on Bluesky!

Loading statistics...

Newsletter

The Svelte Vietnam Blog Newsletter

Subscribe to receive notification for new blog post from Svelte Vietnam

>

Verify (requires Javascript):

In this series

View more

Edit this page on Github sveltevietnam.dev is an open source project and welcomes your contributions. Thank you!