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.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
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
letdeclarations and reactivity directly - Server-side data fetching — use SvelteKit’s
loadfunctions 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
readablefor values that components shouldn’t modify (clocks, WebSocket data) -
Use
derivedfor 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
setandupdatewhen they shouldn’t be public
Common Mistakes
- Using
get()in reactive statements:$: doubled = get(count) * 2doesn’t react to changes. Use$countor 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 = 0is simpler thanconst 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 useconstat module scope. - Not using derived stores: computing values in components from multiple stores manually is error-prone —
derivedhandles updates automatically.
FAQ
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.
Related Resources
Data Fetching with Vue 3 Composition API
How to fetch and manage data in Vue 3 using the Composition API with ref, computed, watch, and composables for reusable data logic.
RecipeWhen to Use useMemo and useCallback
How and when to use React's useMemo and useCallback hooks for performance optimization, and when they add unnecessary overhead.
RecipeContainer Queries for Component Responsiveness
How to use CSS container queries for component-level responsive layouts that adapt to their container size instead of the viewport.
PatternOptimistic Update: Update UI Immediately, Reconcile on
How to update UI immediately and reconcile on server response in React. Covers rollback on error, conflict resolution, and React Query integration.
PatternState Machine UI: Finite State Machines for UI
How to model UI state transitions with finite state machines in React. Covers XState, statecharts, guarded transitions, and preventing impossible states.
GuideReact State Management: Context, Zustand, TanStack Query
Master React state management: Context API, Zustand, Jotai, and TanStack Query for server state. Covers patterns, persistence, optimistic updates, and when to use each.