StackPractices
intermediate By Mathias Paulenko

Reactive State Management with Svelte Stores

How to manage reactive state in Svelte using writable, readable, derived stores, and custom stores with contract-based updates.

Topics: frontend

Overview

Svelte stores are reactive containers for values that need to be shared across components. A store is any object with a subscribe method that notifies subscribers when the value changes. Svelte provides writable (read-write), readable (read-only), and derived (computed from other stores) factories. Components auto-subscribe to stores using the $ prefix in templates, and the subscription is cleaned up automatically when the component unmounts.

When to Use

  • Sharing state across sibling components without prop drilling
  • Global app state (user session, theme, feature flags)
  • Computed state that depends on multiple other stores
  • Cross-component communication (event buses, notification queues)
  • Async data that loads once and is consumed by multiple components

When NOT to Use

  • Component-local state — use Svelte’s let declarations and reactivity directly
  • Server-side data fetching — use SvelteKit’s load functions instead
  • Complex state machines — consider XState or a dedicated state machine library
  • When prop passing is sufficient for 2-3 levels — don’t over-engineer with stores

Solution

Writable store

<script>
  import { writable } from "svelte/store";

  // Create a writable store with initial value
  const count = writable(0);

  function increment() {
    count.update((n) => n + 1);
  }

  function decrement() {
    count.update((n) => n - 1);
  }

  function reset() {
    count.set(0);
  }
</script>

<h1>Count: {$count}</h1>
<button on:click={increment}>+</button>
<button on:click={decrement}>-</button>
<button on:click={reset}>Reset</button>

The $count syntax auto-subscribes in the template. In the script block, use count.set(), count.update(), or get(count).

Shared store in a separate file

// stores/user.js
import { writable } from "svelte/store";

export const user = writable(null);

export async function loginUser(credentials) {
  const response = await fetch("/api/login", {
    method: "POST",
    body: JSON.stringify(credentials),
  });
  const data = await response.json();
  user.set(data);
  return data;
}

export function logoutUser() {
  user.set(null);
}
<!-- Header.svelte -->
<script>
  import { user, logoutUser } from "../stores/user.js";
</script>

{#if $user}
  <span>Welcome, {$user.name}</span>
  <button on:click={logoutUser}>Logout</button>
{:else}
  <a href="/login">Login</a>
{/if}

Readable store (read-only with internal updates)

// stores/time.js
import { readable } from "svelte/store";

export const currentTime = readable(new Date(), (set) => {
  const interval = setInterval(() => {
    set(new Date());
  }, 1000);

  // Cleanup function called when the last subscriber unsubscribes
  return () => clearInterval(interval);
});
<script>
  import { currentTime } from "../stores/time.js";
</script>

<p>Current time: {$currentTime.toLocaleTimeString()}</p>

The store starts the interval when the first subscriber attaches and stops it when the last subscriber leaves — no wasted resources.

Derived store

// stores/cart.js
import { writable, derived } from "svelte/store";

export const cart = writable([]);

export const itemCount = derived(cart, ($cart) =>
  $cart.reduce((sum, item) => sum + item.quantity, 0)
);

export const totalPrice = derived(cart, ($cart) =>
  $cart.reduce((sum, item) => sum + item.price * item.quantity, 0)
);

export const formattedTotal = derived(
  totalPrice,
  ($total) => `$${$total.toFixed(2)}`
);
<script>
  import { cart, itemCount, formattedTotal } from "../stores/cart.js";

  function addItem() {
    cart.update((items) => [
      ...items,
      { id: Date.now(), name: "Product", price: 9.99, quantity: 1 },
    ]);
  }
</script>

<p>Items in cart: {$itemCount}</p>
<p>Total: {$formattedTotal}</p>
<button on:click={addItem}>Add item</button>

Derived from multiple stores

import { derived } from "svelte/store";
import { user } from "./user.js";
import { settings } from "./settings.js";

export const greeting = derived(
  [user, settings],
  ([$user, $settings]) => {
    if (!$user) return "Welcome, guest";
    const name = $settings.formal ? $user.fullName : $user.firstName;
    return `Hello, ${name}`;
  }
);

Custom store with contract

// stores/counter.js
import { writable } from "svelte/store";

function createCounter(start = 0, step = 1) {
  const { subscribe, update, set } = writable(start);

  return {
    subscribe,
    increment: () => update((n) => n + step),
    decrement: () => update((n) => n - step),
    reset: () => set(start),
    setStep: (newStep) => update((n) => n), // step is captured in closure
  };
}

export const counter = createCounter(0, 5);
<script>
  import { counter } from "../stores/counter.js";
</script>

<h1>{$counter}</h1>
<button on:click={counter.increment}>+5</button>
<button on:click={counter.decrement}>-5</button>
<button on:click={counter.reset}>Reset</button>

Store with localStorage persistence

// stores/persistent.js
import { writable } from "svelte/store";

function persistent(key, initialValue) {
  const stored = localStorage.getItem(key);
  const initial = stored ? JSON.parse(stored) : initialValue;

  const store = writable(initial);

  store.subscribe((value) => {
    localStorage.setItem(key, JSON.stringify(value));
  });

  return store;
}

export const theme = persistent("theme", "light");
export const sidebarOpen = persistent("sidebar", false);

Auto-unsubscribe in components

<script>
  import { onMount } from "svelte";
  import { user } from "../stores/user.js";

  // Manual subscription (when you need the value in script logic)
  let currentUser;
  const unsubscribe = user.subscribe((value) => {
    currentUser = value;
  });

  // Cleanup on unmount
  onDestroy(unsubscribe);
</script>

<!-- Auto-subscription with $ prefix (template only) -->
<h1>{$user?.name}</h1>

Using get() for one-time reads

import { get } from "svelte/store";
import { user } from "../stores/user.js";

function checkAuth() {
  const currentUser = get(user);
  if (!currentUser) {
    throw new Error("Not authenticated");
  }
  return currentUser;
}

get() reads the current value without subscribing. Use it for one-time reads in event handlers — don’t use it in reactive declarations.

Store with async updates

// stores/products.js
import { writable } from "svelte/store";

export const products = writable([]);
export const loading = writable(false);
export const error = writable(null);

export async function fetchProducts() {
  loading.set(true);
  error.set(null);
  try {
    const response = await fetch("/api/products");
    if (!response.ok) throw new Error("Failed to fetch");
    const data = await response.json();
    products.set(data);
  } catch (err) {
    error.set(err.message);
  } finally {
    loading.set(false);
  }
}
<script>
  import { onMount } from "svelte";
  import { products, loading, error, fetchProducts } from "../stores/products.js";

  onMount(fetchProducts);
</script>

{#if $loading}
  <p>Loading...</p>
{:else if $error}
  <p>Error: {$error}</p>
{:else}
  <ul>
    {#each $products as product}
      <li>{product.name} — ${product.price}</li>
    {/each}
  </ul>
{/if}

Variants

Using stores with SvelteKit

// src/stores/session.js
import { writable } from "svelte/store";

export const session = writable({
  user: null,
  token: null,
});

export function setSession(data) {
  session.set(data);
  if (data.token) {
    document.cookie = `token=${data.token}; path=/; max-age=3600`;
  }
}
<!-- +layout.svelte -->
<script>
  import { session } from "$lib/stores/session.js";
  import { onMount } from "svelte";

  onMount(() => {
    const token = document.cookie
      .split("; ")
      .find((c) => c.startsWith("token="))
      ?.split("=")[1];

    if (token) {
      fetch("/api/me", { headers: { Authorization: `Bearer ${token}` } })
        .then((r) => r.json())
        .then((user) => session.set({ user, token }));
    }
  });
</script>

{#if $session.user}
  <slot />
{:else}
  <p>Please log in</p>
{/if}

Store with debounce

import { writable } from "svelte/store";

function debouncedStore(initialValue, delay = 300) {
  const store = writable(initialValue);
  let timeout;

  function set(value) {
    clearTimeout(timeout);
    timeout = setTimeout(() => store.set(value), delay);
  }

  return {
    subscribe: store.subscribe,
    set,
    update: (fn) => set(fn(get(store))),
  };
}

export const searchQuery = debouncedStore("", 300);

Best Practices

  • For a deeper guide, see React State Management: Context, Zustand, TanStack Query.

  • Export stores from separate files — keeps components clean and stores reusable

  • Use readable for values that components shouldn’t modify (clocks, WebSocket data)

  • Use derived for computed state — don’t duplicate data that can be calculated from other stores

  • Use the $ prefix in templates — auto-subscribe/unsubscribe is handled by Svelte

  • Use get() only for one-time reads in event handlers — not in reactive declarations

  • Clean up manual subscriptions with onDestroy(unsubscribe) — auto-subscriptions handle this

  • Use custom stores to expose a controlled API — hide set and update when they shouldn’t be public

Common Mistakes

  • Using get() in reactive statements: $: doubled = get(count) * 2 doesn’t react to changes. Use $count or a derived store.
  • Not cleaning up manual subscriptions: calling store.subscribe() without storing the unsubscribe function leaks memory.
  • Overusing stores for local state: let count = 0 is simpler than const count = writable(0) for component-local state.
  • Creating stores inside components: const count = writable(0) inside a component creates a new store on every render. Move it outside or use const at module scope.
  • Not using derived stores: computing values in components from multiple stores manually is error-prone — derived handles updates automatically.

Frequently Asked Questions

What is the $ prefix in Svelte?

It's syntactic sugar for auto-subscribing to a store in templates and reactive declarations. $count is equivalent to subscribing to count and using its current value. Svelte handles subscription and cleanup automatically.

Can I use Svelte stores outside of Svelte components?

Yes. Stores are plain JavaScript objects with a subscribe method. You can subscribe manually in any JavaScript file:

import { user } from "./stores/user.js";

const unsubscribe = user.subscribe((value) => {
  console.log("User changed:", value);
});

// Later
unsubscribe();
What is the store contract?

Any object with a subscribe method that returns an unsubscribe function is a store. The method must accept a callback and call it immediately with the current value, then on every change.

How do I test a store?
import { get } from "svelte/store";
import { counter } from "./stores/counter.js";

test("counter increments", () => {
  counter.reset();
  counter.increment();
  expect(get(counter)).toBe(5); // step is 5
});
Should I use stores or context API for component trees?

Use context (setContext/getContext) for data scoped to a component subtree — it's simpler and doesn't require stores. Use stores for global state shared across the entire app or when you need reactivity outside components.