Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 087 / 100

Svelte stores in depth for Svelte & SvelteKit developers

Writable, readable and derived stores.

Expona Admin8 min read

Introduction

State management can make or break a frontend application, especially as your Svelte & SvelteKit projects scale. Understanding Svelte stores in depth is essential for building maintainable, reactive systems without unnecessary complexity. In this article, we'll explore writable stores, readable stores, derived stores, and the modern store patterns that keep your application predictable and performant.

Why Svelte Stores Matter

Before we dive into the mechanics, let's establish why stores matter. In any non-trivial application—whether it's an e-commerce checkout flow, a CRM dashboard, or a ride-hailing platform—you need shared state that multiple components can read and modify. Svelte stores give you a lightweight, reactive way to manage that state without prop drilling or external libraries like Redux.

The core advantage: stores are automatically reactive. When a store's value changes, any component subscribing to it updates instantly. This keeps your UI in sync with your data with minimal boilerplate.

Writable Stores for Mutable Values

A writable store is the bread-and-butter tool for shared mutable state. It's a store you can both read from and write to, and it notifies all subscribers whenever the value changes.

Here's a real-world scenario: you're building a shopping cart. Multiple components need to add items, remove items, and display the cart total. A writable store is perfect for this.

typescript
import { writable } from 'svelte/store';

export const cart = writable([]);

export function addItem(product) {
  cart.update(items => [...items, product]);
}

export function removeItem(productId) {
  cart.update(items => items.filter(item => item.id !== productId));
}

export function clearCart() {
  cart.set([]);
}

In your components, you use the store with the $ prefix—Svelte's auto-subscription syntax—to automatically subscribe and unsubscribe:

typescript
<script>
  import { cart, addItem, removeItem } from './store.js';
</script>

<div>
  <p>Items in cart: {$cart.length}</p>
  {#each $cart as item (item.id)}
    <div>
      {item.name} - ${item.price}
      <button on:click={() => removeItem(item.id)}>Remove</button>
    </div>
  {/each}
</div>

The update() method gives you access to the current value and lets you compute the next state. The set() method replaces the value entirely. Both trigger subscriber notifications.

For complex updates, prefer update() over set() because it's atomic and avoids race conditions in concurrent scenarios. If multiple updates fire rapidly, update() ensures each one sees the most recent state.

Readable Stores for Read-Only State

Sometimes you need shared state that components shouldn't directly modify. A readable store is write-protected at the component level—only the store's creation logic can update it.

Consider a real-time data feed: you're fetching stock prices from a WebSocket every second. Multiple dashboard widgets need to display the current price, but they shouldn't be able to write to it directly.

typescript
import { readable } from 'svelte/store';

export const stockPrice = readable(null, (set) => {
  const ws = new WebSocket('wss://api.example.com/prices');
  
  ws.onmessage = (event) => {
    const data = JSON.parse(event.data);
    set(data.price);
  };
  
  // Cleanup function
  return () => ws.close();
});

The second argument to readable() is a subscription function. It receives the set function, which updates the store's value. When the last subscriber unsubscribes, the cleanup function runs (returning the cleanup logic ensures resources like WebSocket connections are properly closed).

Components use readable stores the same way as writable stores:

typescript
<script>
  import { stockPrice } from './store.js';
</script>

<p>Current price: ${$stockPrice}</p>

The enforced read-only constraint at the component level prevents accidental mutations and makes the data flow clearer. If a component needs to trigger updates, it explicitly calls a function from the store module rather than mutating the store directly.

Derived Stores for Computed State

Derived stores are computed values that automatically update when their dependencies change. They're perfect for avoiding manual synchronization and keeping derived logic in one place.

Let's say you're building a CRM dashboard. You have a users store, and you need to show a filtered list of active users and a count. Instead of computing these in each component, use derived stores:

typescript
import { writable, derived } from 'svelte/store';

export const users = writable([
  { id: 1, name: 'Alice', active: true },
  { id: 2, name: 'Bob', active: false },
  { id: 3, name: 'Charlie', active: true }
]);

export const activeUsers = derived(
  users,
  ($users) => $users.filter(u => u.active)
);

export const activeUserCount = derived(
  activeUsers,
  ($activeUsers) => $activeUsers.length
);

When users changes, activeUsers recomputes automatically. When activeUsers changes, activeUserCount recomputes. This cascading reactivity is powerful and efficient—Svelte only recomputes what's needed.

You can also derive from multiple stores at once:

typescript
export const searchQuery = writable('');

export const filteredUsers = derived(
  [users, searchQuery],
  ([$users, $searchQuery]) => {
    const query = $searchQuery.toLowerCase();
    return $users.filter(u => 
      u.name.toLowerCase().includes(query) && u.active
    );
  }
);

In your component, derived stores feel identical to any other store:

typescript
<script>
  import { activeUserCount, filteredUsers } from './store.js';
</script>

<p>Active users: {$activeUserCount}</p>
<ul>
  {#each $filteredUsers as user (user.id)}
    <li>{user.name}</li>
  {/each}
</ul>

The beauty of derived stores is that they're lazy—they only compute when you subscribe to them. If no component is displaying the derived value, it doesn't waste cycles recalculating.

The $ Auto-Subscription Magic

Svelte's $ prefix is syntactic sugar that handles subscription plumbing for you. Without it, you'd manually manage subscriptions and unsubscriptions:

typescript
<script>
  import { cart } from './store.js';
  let items;
  
  const unsubscribe = cart.subscribe(value => {
    items = value;
  });
  
  onDestroy(unsubscribe);
</script>

<p>Items: {items.length}</p>

With the $ prefix, Svelte compiles this boilerplate away:

typescript
<script>
  import { cart } from './store.js';
</script>

<p>Items: {$cart.length}</p>

This works in component scripts, markup, and reactive statements. Svelte automatically unsubscribes when the component unmounts, preventing memory leaks.

You can use the $ prefix in reactive blocks too:

typescript
<script>
  import { cart } from './store.js';
  let total = 0;
  
  $: total = $cart.reduce((sum, item) => sum + item.price, 0);
</script>

The reactive block reruns whenever $cart changes, keeping total in sync.

Using Runes for Modern Shared State

SvelteKit 2.0+ introduced runes, a new reactivity primitive. While stores remain valuable, runes offer a more flexible approach for shared state in some scenarios. The $state rune lets you create reactive objects that work across components without explicit subscription logic:

typescript
import { $state } from 'svelte';

export let appState = $state({
  user: null,
  loading: false,
  error: null
});

export function setUser(user) {
  appState.user = user;
  appState.loading = false;
}

export function setLoading(loading) {
  appState.loading = loading;
}

In components, you can access rune-based state directly without the $ prefix:

typescript
<script>
  import { appState } from './store.js';
</script>

{#if appState.loading}
  <p>Loading...</p>
{:else if appState.user}
  <p>Welcome, {appState.user.name}</p>
{/if}

Runes are powerful, but they blur the line between local and shared state. For explicit, cross-cutting state management, traditional stores remain clearer. Use runes when you're managing component-tree state that doesn't need the formal contract a store provides.

Common Mistakes

Mutating store values directly

This is the most frequent error. Directly mutating an array or object in a store doesn't trigger reactivity:

typescript
// ❌ Wrong: mutation doesn't trigger subscribers
$cart.push(item);

// ✅ Correct: use update() to ensure reactivity
cart.update(items => [...items, item]);

Svelte's reactivity system relies on reference changes. Mutating in-place keeps the reference the same, so subscribers don't know anything changed.

Creating stores outside modules

Stores should live in dedicated modules so they're singletons shared across your app. Creating them inside components means you get a new store instance every time the component mounts:

typescript
// ❌ Wrong: new store per component
<script>
  const cart = writable([]);
</script>

// ✅ Correct: shared store module
import { cart } from './store.js';

Forgetting cleanup in subscription functions

If you use manual subscriptions (without the $ prefix), always unsubscribe in cleanup:

typescript
// ❌ Memory leak: no unsubscribe
const unsub = cart.subscribe(value => console.log(value));

// ✅ Proper cleanup
const unsub = cart.subscribe(value => console.log(value));
onDestroy(unsub);

The $ prefix automates this, so prefer it when possible.

Over-deriving stores

It's tempting to derive everything, but each derived store adds computation. If you're deriving a derived store from a derived store from another derived store, step back and consolidate the logic.

Key Takeaways

  • Writable stores are for shared mutable state; use update() for atomic changes and set() for replacements.
  • Readable stores enforce read-only contracts and are ideal for async data sources like WebSockets; cleanup functions ensure resources are properly released.
  • Derived stores automatically compute values when dependencies change; they're lazy and only recompute when subscribed.
  • The $ auto-subscription prefix is syntactic sugar that Svelte compiles away; use it to avoid manual subscription boilerplate and prevent memory leaks.
  • Always trigger reactivity through update() or set(), never by mutating in-place; reference changes are what drive Svelte's reactivity.
  • Keep stores in dedicated modules so they remain singletons shared across your entire application.
  • Modern runes offer an alternative for component-tree state, but traditional stores remain the clearest choice for cross-cutting application state.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles