I hit refresh. Everything was gone.
The theme I selected. The items in my cart. The form I was halfway through filling. All of it, vanished. Back to the default state like nothing ever happened.
This is the reality of client-side applications. State lives in memory. Memory clears on refresh. Users start over.
I kept running into this problem across different projects. A settings page that forgot user preferences. A multi-step form that lost progress. A search feature that couldn’t remember what users searched before.
The solution was always the same: localStorage. But getting it right in Svelte 5, especially with the new runes system and server-side rendering, took some learning.
The Problem: State That Doesn’t Survive
By default, Svelte state exists only in memory:
let theme = $state('dark');
let searchHistory = $state<string[]>([]);
let formData = $state({ name: '', email: '' });
This works perfectly until the user:
- Refreshes the page
- Closes and reopens the browser
- Navigates away and comes back
Every interaction, every preference, every bit of progress disappears.
The user experience cost:
| Scenario | Without Persistence | With Persistence |
|---|---|---|
| Theme selection | Re-select every visit | Set once, remembered forever |
| Search history | Retype previous searches | One-click access to recent searches |
| Form progress | Start over if interrupted | Resume where you left off |
| Filter preferences | Reset every session | Preserved across sessions |
Users don’t think about this consciously. But they feel it. Apps that remember feel polished. Apps that forget feel broken.
Why localStorage?
Before diving into implementation, it’s worth understanding why localStorage is the right choice for this problem.
localStorage vs alternatives:
| Storage | Persistence | Size Limit | Accessibility |
|---|---|---|---|
| Memory (default) | Until refresh | Unlimited | JavaScript only |
| sessionStorage | Until tab closes | ~5MB | JavaScript only |
| localStorage | Forever | ~5MB | JavaScript only |
| Cookies | Configurable | ~4KB | Server + JavaScript |
| IndexedDB | Forever | Large | JavaScript only |
For user preferences, search history, and form state, localStorage hits the sweet spot: persistent, simple API, and sufficient storage.
The Svelte 5 Approach
Svelte 5 introduced runes ($state, $effect, $derived), replacing the older store-based reactivity. This changes how we approach persistence.
The pattern:
flowchart LR
A[Page Load] --> B{localStorage<br/>has data?}
B -->|Yes| C[Initialize $state<br/>from localStorage]
B -->|No| D[Initialize $state<br/>with default]
C --> E[Component Renders]
D --> E
E --> F[User Interaction]
F --> G[$state Updates]
G --> H[$effect Triggers]
H --> I[Save to localStorage]
I --> F
- Initialize state from localStorage (if available)
- Use
$effectto sync state changes back to localStorage - Handle server-side rendering (localStorage doesn’t exist on the server)
Let me show you how this works with a concrete example: persisting user preferences.
Building a Persistent Preferences System
Step 1: Create a Type-Safe Storage Utility
The first challenge is that localStorage only stores strings. You need a wrapper that handles serialization, parsing errors, and SSR safety.
// src/lib/storage.ts
export function getStoredValue<T>(key: string, defaultValue: T): T {
// Guard: localStorage doesn't exist during SSR
if (typeof window === 'undefined') {
return defaultValue;
}
try {
const stored = localStorage.getItem(key);
if (stored === null) {
return defaultValue;
}
return JSON.parse(stored) as T;
} catch {
// JSON parsing failed - data might be corrupted
return defaultValue;
}
}
export function setStoredValue<T>(key: string, value: T): void {
if (typeof window === 'undefined') {
return;
}
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
// Storage might be full or disabled
console.warn(`Failed to save ${key} to localStorage`);
}
}
export function removeStoredValue(key: string): void {
if (typeof window === 'undefined') {
return;
}
localStorage.removeItem(key);
}
Why all the guards?
flowchart TD
A[getStoredValue Called] --> B{typeof window<br/>!== 'undefined'?}
B -->|No - SSR| C[Return defaultValue]
B -->|Yes - Browser| D[localStorage.getItem]
D --> E{Value exists?}
E -->|No| C
E -->|Yes| F[JSON.parse]
F -->|Success| G[Return parsed value]
F -->|Error| H[Corrupted data]
H --> C
-
SSR Check: Svelte can run on the server where
windowdoesn’t exist. Without this check, your build fails with “window is not defined.” -
Try-Catch: localStorage can fail. Users might have it disabled. Storage might be full. The browser might be in private mode with restrictions.
-
Parse Protection:
JSON.parse()throws on invalid input. Users can manually edit localStorage. Browser extensions can corrupt it. Always handle the error case.
Step 2: Create a Persistent State Rune
Now, create a reusable function that combines Svelte 5’s $state with localStorage:
// src/lib/persisted.svelte.ts
import { getStoredValue, setStoredValue } from './storage';
export function createPersistedState<T>(key: string, initialValue: T) {
// Initialize from localStorage or use default
let value = $state<T>(getStoredValue(key, initialValue));
// Sync to localStorage whenever value changes
$effect(() => {
setStoredValue(key, value);
});
return {
get value() {
return value;
},
set value(newValue: T) {
value = newValue;
},
reset() {
value = initialValue;
removeStoredValue(key);
}
};
}
How this works:
-
On first load,
getStoredValuechecks localStorage. If data exists, it uses that. Otherwise, it usesinitialValue. -
The
$effectruns automatically whenevervaluechanges. It saves the new value to localStorage. -
The getter/setter pattern provides a clean API while maintaining reactivity.
Step 3: Use It in Components
Now using persisted state is simple:
<script lang="ts">
import { createPersistedState } from '$lib/persisted.svelte';
// Theme preference - persists across sessions
const theme = createPersistedState('user-theme', 'system');
// Search history - remembers recent searches
const searchHistory = createPersistedState<string[]>('search-history', []);
function addToHistory(query: string) {
if (!query.trim()) return;
// Remove duplicates, add to front, limit to 10
const filtered = searchHistory.value.filter(item => item !== query);
searchHistory.value = [query, ...filtered].slice(0, 10);
}
function clearHistory() {
searchHistory.reset();
}
</script>
<select bind:value={theme.value}>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<input
type="text"
onkeydown={(e) => {
if (e.key === 'Enter') {
addToHistory(e.currentTarget.value);
}
}}
/>
{#if searchHistory.value.length > 0}
<ul>
{#each searchHistory.value as query}
<li>{query}</li>
{/each}
</ul>
<button onclick={clearHistory}>Clear History</button>
{/if}
Refresh the page. The theme stays. The search history stays. State survives.
Handling Complex Data
The pattern scales to more complex scenarios.
Example: Multi-Step Form Progress
interface FormData {
step: number;
personal: { name: string; email: string };
preferences: { newsletter: boolean; notifications: boolean };
completed: boolean;
}
const defaultForm: FormData = {
step: 1,
personal: { name: '', email: '' },
preferences: { newsletter: false, notifications: true },
completed: false
};
const formProgress = createPersistedState('signup-form', defaultForm);
// User can close browser, come back, and resume where they left off
function nextStep() {
formProgress.value = {
...formProgress.value,
step: formProgress.value.step + 1
};
}
function submitForm() {
// Process the form...
formProgress.reset(); // Clear after successful submission
}
Example: Recently Viewed Items
interface ViewedItem {
id: string;
title: string;
viewedAt: string;
}
const recentlyViewed = createPersistedState<ViewedItem[]>('recently-viewed', []);
function trackView(item: { id: string; title: string }) {
const existing = recentlyViewed.value.filter(v => v.id !== item.id);
recentlyViewed.value = [
{ ...item, viewedAt: new Date().toISOString() },
...existing
].slice(0, 20); // Keep last 20 items
}
Common Pitfalls and Solutions
Pitfall 1: Hydration Mismatch
When using SSR, the server renders with the default value (it can’t access localStorage). The client then hydrates with the persisted value. This can cause a “hydration mismatch” warning.
sequenceDiagram
participant Server
participant Browser
participant localStorage
Server->>Browser: HTML with default value ("system")
Browser->>localStorage: Check for stored value
localStorage-->>Browser: Returns "dark"
Note over Browser: Mismatch! Server sent "system"<br/>but localStorage has "dark"
Browser->>Browser: Hydration warning ⚠️
Solution: Delay reading localStorage until after mount:
import { onMount } from 'svelte';
let theme = $state('system');
let mounted = $state(false);
onMount(() => {
theme = getStoredValue('theme', 'system');
mounted = true;
});
// Only show theme-dependent UI after mount
$effect(() => {
if (mounted) {
document.documentElement.setAttribute('data-theme', theme);
}
});
Pitfall 2: Stale Closures in Effects
Be careful with how you reference state in effects:
// WRONG - captures stale value
$effect(() => {
const currentValue = value; // Captured once
localStorage.setItem(key, JSON.stringify(currentValue));
});
// CORRECT - reads current value each time
$effect(() => {
localStorage.setItem(key, JSON.stringify(value));
});
Pitfall 3: Storage Events for Multi-Tab Sync
If users have multiple tabs open, changes in one tab won’t reflect in others by default.
Solution: Listen for storage events:
import { onMount, onDestroy } from 'svelte';
onMount(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === 'user-theme' && e.newValue) {
theme = JSON.parse(e.newValue);
}
};
window.addEventListener('storage', handleStorage);
return () => {
window.removeEventListener('storage', handleStorage);
};
});
When Not to Use localStorage
localStorage isn’t always the answer:
Don’t use it for:
- Sensitive data (tokens, passwords) - use httpOnly cookies instead
- Large datasets (>2MB) - use IndexedDB
- Data that must sync across devices - use a backend
- Temporary state that should clear on tab close - use sessionStorage
Do use it for:
- UI preferences (theme, layout, sidebar state)
- Non-sensitive history (search queries, recently viewed)
- Form progress (drafts, incomplete submissions)
- Feature flags and A/B test assignments
The Complete Pattern
Putting it all together, here’s the mental model:
flowchart TB
subgraph Component["Svelte Component"]
A["$state"] -->|User interactions<br/>modify state| B["$effect"]
end
subgraph Storage["Browser Storage"]
C["localStorage<br/>(persists across sessions)"]
end
C -->|"Initial load<br/>(page load / refresh)"| A
B -->|"Sync changes<br/>(automatic)"| C
D["🔄 Browser Refresh"] -.->|Triggers reload| C
State flows from localStorage into your reactive $state. User actions modify the state. The $effect catches every change and writes it back to localStorage. On refresh, the cycle begins again, but now localStorage has data.
What I Learned
Building persistent state in Svelte 5 taught me a few things:
1. SSR awareness is non-negotiable. Every browser API access needs a guard. It’s easy to forget when developing locally, but builds will fail without it.
2. Defensive coding pays off. localStorage can fail in ways you don’t expect. Try-catch blocks and fallback values prevent mysterious bugs.
3. The runes system is elegant. $state + $effect creates a clean reactive loop. No subscriptions to manage, no cleanup to forget.
4. Small UX wins compound. Users notice when apps remember their preferences. They might not articulate it, but apps that persist state feel more polished, more professional, more trustworthy.
Resources
Svelte 5 Runes:
localStorage:
Persistence Libraries:
- svelte-persisted-store - Store-based approach
- @friendofsvelte/state - Runes-compatible
The next time you build a Svelte app, ask yourself: what should this app remember? The answer is probably more than you think. And now you know how to make it happen.