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:

ScenarioWithout PersistenceWith Persistence
Theme selectionRe-select every visitSet once, remembered forever
Search historyRetype previous searchesOne-click access to recent searches
Form progressStart over if interruptedResume where you left off
Filter preferencesReset every sessionPreserved 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:

StoragePersistenceSize LimitAccessibility
Memory (default)Until refreshUnlimitedJavaScript only
sessionStorageUntil tab closes~5MBJavaScript only
localStorageForever~5MBJavaScript only
CookiesConfigurable~4KBServer + JavaScript
IndexedDBForeverLargeJavaScript 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
  1. Initialize state from localStorage (if available)
  2. Use $effect to sync state changes back to localStorage
  3. 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
  1. SSR Check: Svelte can run on the server where window doesn’t exist. Without this check, your build fails with “window is not defined.”

  2. Try-Catch: localStorage can fail. Users might have it disabled. Storage might be full. The browser might be in private mode with restrictions.

  3. 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:

  1. On first load, getStoredValue checks localStorage. If data exists, it uses that. Otherwise, it uses initialValue.

  2. The $effect runs automatically whenever value changes. It saves the new value to localStorage.

  3. 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:


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.