So I’ve been playing around with Svelte 5’s new runes system lately, and honestly? It’s pretty amazing for building reusable utilities. I ended up creating this persistence layer and theming system that I’m actually really happy with, so I figured I’d share what I learned.
The localStorage Nightmare We All Know
Okay, we’ve all been there right? You just want to save some user preferences to localStorage, and you end up with this mess:
// Ugh, so much boilerplate and no type safety
const theme = localStorage.getItem('theme') || 'light';
if (theme !== 'light' && theme !== 'dark') {
localStorage.removeItem('theme'); // Invalid data, clear it
}
And then you realize you need to handle like… everything:
- What if someone messes with localStorage manually? (they will)
- Cross-tab synchronization (surprisingly annoying)
- Making sure your state actually updates
- Cleaning up listeners when components unmount
It’s way more complex than it should be for something so basic!
Enter the Persisted Utility
So here’s what I came up with using Svelte 5 runes and Zod v4 mini (more on why I picked the mini version later):
const userPrefs = new Persisted(
'user:preferences',
{ theme: 'light', sidebar: 'open' }, // default value
z.object({
theme: z.enum(['light', 'dark']),
sidebar: z.enum(['open', 'closed']),
}),
);
// Now you can just use it reactively!
$effect(() => {
console.log('User theme is:', userPrefs.current.theme);
});
// And update it safely
userPrefs.current = { theme: 'dark', sidebar: 'closed' };
Not gonna lie, I’m pretty proud of how clean this turned out.
What Makes This Special
Type Safety First: By using Zod schemas, we get runtime validation plus TypeScript types automatically. If someone manually edits localStorage with invalid data (and trust me, they will), it just gets cleared and falls back to defaults. No more mysterious bugs!
Cross-Tab Sync: The utility listens for storage events, so when you change a preference in one tab, all other tabs automatically update. This was actually way trickier to implement than I expected - lots of edge cases around timing and what events to listen for.
Automatic Cleanup: Using a custom auto_destroy_effect_root helper, all effects get cleaned up when components unmount. No memory leaks to debug later (learned this the hard way on another project).
Flexible Serialization: Defaults to JSON which works for most stuff, but you can pass custom serializers if you need to persist weird data types.
Building an Automatic Theme System
With the Persisted utility working, building a theme system became surprisingly simple:
const theme = new Theme(); // That's literally it!
// Automatically detects system preference
$effect(() => {
console.log('Current theme:', theme.current); // 'light' or 'dark'
});
// Set preference
theme.preference = 'dark'; // or 'light' or 'system'
The Magic Behind the Scenes
The theme system is basically just a few reactive pieces working together:
- Persisted preferences using our utility above
- System preference detection using Svelte’s
MediaQueryrune (which is pretty cool btw) - Derived state that automatically figures out whether to use manual or system themes
- DOM updates with view transitions for that smooth theme switching everyone expects these days
#current = $derived.by(() => {
this.#media_observer.current; // React to system changes
if (this.#persisted.current.preference === 'system') {
return this.#media_observer.current ? 'dark' : 'light';
}
return this.#persisted.current.preference;
});
So when macOS switches to dark mode at sunset (or whenever you have it set), or when the user manually picks a preference, everything just… works. Plus the view transitions make it feel really polished.
The Power of Runes
What I really love about this approach is how declarative everything feels. The old Svelte stores system was fine, but you had to manage so much subscription stuff manually. With runes, you just describe what should happen:
- “When the theme changes, update the DOM”
- “When localStorage changes in another tab, update our state”
- “When the component unmounts, clean everything up”
And the reactive system just… handles it. No thinking about timing or cleanup or any of that stuff.
Implementation Details
Avoiding the First-Run Problem
One annoying thing I had to figure out was preventing the persistence layer from immediately writing to localStorage when it first loads:
let is_first_run = true;
$effect(() => {
const current = $state.snapshot(this.#current);
if (!is_first_run) {
localStorage.setItem(key, serde.stringify(current));
}
is_first_run = false;
});
This way we only write to localStorage when the state actually changes, not when we’re just loading from it initially. Took me a bit to realize why I was getting weird loops without this.
Browser-Only Execution
The utilities handle SSR gracefully by checking if we’re in the browser before touching localStorage. On the server, they just use the default values. Pretty straightforward but worth mentioning.
Custom Serialization
JSON works for most stuff, but sometimes you need custom serializers:
const complexState = new Persisted('complex:state', new Map(), mapSchema, {
stringify: (value) => JSON.stringify(Array.from(value.entries())),
parse: (value) => new Map(JSON.parse(value)),
});
Haven’t needed this yet myself but it’s nice to know it’s there.
Why Zod v4 Mini?
I went with Zod v4 mini specifically because it’s way smaller than full Zod but still has the validation stuff you actually need. For client-side persistence, you usually don’t need all the crazy schema transformation features - just basic type checking and validation. Plus smaller bundle size is always nice.
Wrapping Up
Building these utilities really showed me how powerful Svelte 5’s runes can be for creating clean, reusable stuff. The combination of:
- Type safety from Zod
- Reactivity from runes
- Automatic cleanup from effect roots
- Cross-tab sync from storage events
…creates something that’s both powerful and surprisingly simple to actually use.
The best part? These utilities feel like they could be part of Svelte itself. They follow the same patterns and conventions, so they’re easy to understand if you already know Svelte.
If you’re working with Svelte 5, I’d definitely recommend exploring similar patterns. The runes system really shines when you start building these kinds of reactive utilities that handle complex state stuff automatically.
Anyway, hope this was helpful! Let me know if you build something similar - I’d love to see other approaches.
Complete Code Reference
Here’s the full implementation for reference:
persisted.svelte.ts
import { browser } from '$helpers/utils.ts';
import { on } from 'svelte/events';
import { createSubscriber } from 'svelte/reactivity';
import { parse, type ZodMiniType } from 'zod/v4-mini';
import { auto_destroy_effect_root } from './auto-destroy-effect-root.svelte.ts';
export type Serde = {
stringify: (value: any) => string;
parse: (value: string) => any;
};
const default_serde: Serde = {
stringify: (value) => JSON.stringify(value),
parse: (value) => JSON.parse(value),
};
type ExtractZodType<T> = T extends ZodMiniType<infer U> ? U : never;
function get_value_from_storage(
key: string,
shape: ZodMiniType<any>,
serde = default_serde,
) {
const value = localStorage.getItem(key);
if (!value) return { found: false, value: null };
try {
return {
found: true,
value: parse(shape, serde.parse(value)),
};
} catch (e) {
localStorage.removeItem(key);
return {
found: false,
value: null,
};
}
}
export class Persisted<T extends ZodMiniType> {
#current = $state<ExtractZodType<T>>(undefined as ExtractZodType<T>);
#subscribe: () => void;
#key: string;
constructor(
key: string,
initial: ExtractZodType<T>,
shape: T,
serde = default_serde,
) {
this.#current = initial;
this.#key = key;
if (browser) {
const val = get_value_from_storage(key, shape, serde);
if (val.found) {
this.#current = val.value;
}
}
// Create subscriber that only triggers for this specific key
this.#subscribe = createSubscriber((update) => {
return on(window, 'storage', (e: StorageEvent) => {
if (e.key === this.#key) {
const val = get_value_from_storage(this.#key, shape, serde);
if (val.found) {
this.#current = val.value;
update();
}
}
});
});
auto_destroy_effect_root(() => {
let is_first_run = true;
$effect(() => {
this.#subscribe();
const current = $state.snapshot(this.#current);
if (!is_first_run) {
localStorage.setItem(key, serde.stringify(current));
}
is_first_run = false;
});
});
}
get current() {
return this.#current;
}
set current(value: ExtractZodType<T>) {
this.#current = value;
}
}
theme.svelte.ts
import { MediaQuery } from 'svelte/reactivity';
import { Persisted } from './persisted.svelte';
import { auto_destroy_effect_root } from './auto-destroy-effect-root.svelte';
import * as z from 'zod/v4/mini';
const schema = z.object({
current: z.enum(['light', 'dark']),
preference: z.enum(['system', 'light', 'dark']),
});
export type ThemeValue = z.infer<typeof schema>;
function apply_theme_to_dom(new_theme: string) {
document.body.dataset.theme = new_theme;
}
class Theme {
#persisted = new Persisted(
'neodrag:theme',
{
current: 'light',
preference: 'system',
},
schema,
);
#media_observer = new MediaQuery('prefers-color-scheme: dark');
#current = $derived.by(() => {
this.#media_observer.current;
if (this.#persisted.current.preference === 'system') {
return this.#media_observer.current ? 'dark' : 'light';
}
return this.#persisted.current.preference;
});
constructor() {
auto_destroy_effect_root(() => {
$effect(() => {
this.#current;
requestAnimationFrame(() => {
if (document.startViewTransition) {
document.startViewTransition(async () => {
apply_theme_to_dom(this.#current);
});
} else {
apply_theme_to_dom(this.#current);
}
});
});
});
}
get current() {
return this.#current;
}
get preference() {
return this.#persisted.current.preference;
}
set preference(value: ThemeValue['preference']) {
this.#persisted.current.preference = value;
if (value !== 'system') {
this.#persisted.current.current = value;
}
}
}
export const theme = new Theme();
auto-destroy-effect-root.svelte.ts
import { onDestroy } from 'svelte';
/**
* Behaves the same as `$effect.root`, but automatically
* cleans up the effect inside Svelte components.
*
* @returns Cleanup function to manually cleanup the effect.
*/
export function auto_destroy_effect_root(fn: () => void | VoidFunction) {
let cleanup: VoidFunction | null = $effect.root(fn);
function destroy() {
if (cleanup === null) {
return;
}
cleanup();
cleanup = null;
}
try {
onDestroy(destroy);
} catch {
// Ignore the error. The user is responsible for manually
// cleaning up effects created outside Svelte components.
}
return destroy;
}
Want to see more Svelte 5 patterns like this? Let me know what other utilities would be useful!