Introduction to State Management in Svelte
State management is one of the most important concepts in modern frontend development. In Svelte, state management is built into the framework's reactivity system, making it both powerful and intuitive. Unlike frameworks that require external libraries for basic state handling, Svelte provides native primitives that cover most use cases, while still allowing you to integrate specialized libraries when your application grows in complexity.
This tutorial covers the core patterns of state management in Svelte, from local component state to global stores, and explores popular libraries that extend Svelte's capabilities for larger applications.
What Is State Management?
State refers to any data that changes over time and drives the UI of your application. This includes user input, fetched data, UI flags like modal visibility, authentication status, and more. State management is the practice of organizing, sharing, and updating this data in a predictable and maintainable way.
In Svelte, reactivity is baked into the language through assignments. When you assign a new value to a variable, the component re-renders. This simplicity scales up through stores, which allow state to be shared across components without prop drilling.
Why State Management Matters
- Predictability: A clear state structure makes it easier to reason about how data flows through your app.
- Maintainability: Centralized state reduces duplication and makes refactoring safer.
- Performance: Fine-grained reactivity in Svelte means only the parts of the UI that depend on changed state are updated.
- Testability: Decoupled state logic is easier to unit test in isolation.
- Scalability: As your app grows, structured state patterns prevent spaghetti code and prop drilling.
Local Component State
The simplest form of state in Svelte is local component state. Any variable declared in a component's script block is reactive โ reassigning it triggers a re-render.
<script>
let count = 0;
function increment() {
count += 1;
}
function reset() {
count = 0;
}
</script>
<button on:click={increment}>Count: {count}</button>
<button on:click={reset}>Reset</button>
For derived values, Svelte provides the $: reactive label, which recomputes whenever its dependencies change.
<script>
let price = 100;
let quantity = 2;
let taxRate = 0.2;
$: subtotal = price * quantity;
$: tax = subtotal * taxRate;
$: total = subtotal + tax;
</script>
<p>Subtotal: ${subtotal}</p>
<p>Tax: ${tax}</p>
<p>Total: ${total}</p>
Reactive Statements for Side Effects
Reactive statements are not limited to computing values. They can also trigger side effects whenever dependencies change.
<script>
let searchTerm = '';
$: if (searchTerm.length > 2) {
console.log('Searching for:', searchTerm);
// performSearch(searchTerm);
}
</script>
<input bind:value={searchTerm} placeholder="Type to search..." />
Svelte Stores: Sharing State Across Components
When state needs to be shared between components that are not directly connected, Svelte stores come into play. A store is simply an object with a subscribe method that allows components to listen for changes. Svelte provides three built-in store creators: writable, readable, and derived.
Writable Stores
A writable store allows both reading and updating of its value. You create one using writable with an optional initial value.
// stores.js
import { writable } from 'svelte/store';
export const user = writable({
name: 'Guest',
isAuthenticated: false
});
export function login(name) {
user.set({ name, isAuthenticated: true });
}
export function logout() {
user.set({ name: 'Guest', isAuthenticated: false });
}
To consume a store in a component, use the $ prefix, which auto-subscribes and unsubscribes.
<script>
import { user, login, logout } from './stores.js';
</script>
{#if $user.isAuthenticated}
<p>Welcome, {$user.name}!</p>
<button on:click={logout}>Log out</button>
{:else}
<button on:click={() => login('Alice')}>Log in</button>
{/if}
Readable Stores
Readable stores are useful for values that components should observe but not directly modify, such as the current time or geolocation data.
// stores.js
import { readable } from 'svelte/store';
export const currentTime = readable(new Date(), (set) => {
const interval = setInterval(() => {
set(new Date());
}, 1000);
return () => {
clearInterval(interval);
};
});
<script>
import { currentTime } from './stores.js';
</script>
<p>Current time: {$currentTime.toLocaleTimeString()}</p>
Derived Stores
Derived stores compute their value from one or more other stores. This is the store equivalent of a reactive statement.
// stores.js
import { writable, derived } from 'svelte/store';
export const todos = writable([]);
export const filter = writable('all');
export const filteredTodos = derived(
[todos, filter],
([$todos, $filter]) => {
if ($filter === 'active') return $todos.filter(t => !t.done);
if ($filter === 'completed') return $todos.filter(t => t.done);
return $todos;
}
);
export const remainingCount = derived(
todos,
($todos) => $todos.filter(t => !t.done).length
);
Custom Stores
Any object that implements the subscribe method is a valid Svelte store. This lets you create custom stores with controlled APIs, encapsulating internal state and exposing only the methods you want.
// counterStore.js
import { writable } from 'svelte/store';
function createCounter(initial = 0, step = 1) {
const { subscribe, set, update } = writable(initial);
return {
subscribe,
increment: () => update(n => n + step),
decrement: () => update(n => n - step),
reset: () => set(initial)
};
}
export const counter = createCounter(0, 5);
<script>
import { counter } from './counterStore.js';
</script>
<p>Value: {$counter}</p>
<button on:click={counter.decrement}>-</button>
<button on:click={counter.increment}>+</button>
<button on:click={counter.reset}>Reset</button>
Context API for Component Trees
For state that only needs to be shared within a specific subtree of your component hierarchy, the Context API is often a better choice than a global store. Context avoids prop drilling without exposing state to the entire application.
// Parent.svelte
<script>
import { setContext } from 'svelte';
import { writable } from 'svelte/store';
const theme = writable('light');
setContext('theme', theme);
</script>
<slot />
// Child.svelte
<script>
import { getContext } from 'svelte';
const theme = getContext('theme');
</script>
<button on:click={() => theme.update(t => t === 'light' ? 'dark' : 'light')}>
Current theme: {$theme}
</button>
Context is not reactive by itself, but when you store a writable store in context, consumers can subscribe to it using the $ prefix.
State Management Libraries for Svelte
While Svelte's built-in stores handle most scenarios, several libraries offer additional features like state machines, devtools, and middleware.
Svelte Store Extensions
The svelte/store module is minimal by design. Libraries like svelte-writable-derived add bidirectional derived stores, while svelte-local-storage-store persists store values to localStorage automatically.
// stores.js
import { writable } from 'svelte-local-storage-store';
// Automatically synced with localStorage under the key 'preferences'
export const preferences = writable('preferences', {
theme: 'light',
fontSize: 14,
notifications: true
});
XState with Svelte
For complex, stateful logic with many transitions, finite state machines provide a robust solution. XState integrates cleanly with Svelte stores.
// machine.js
import { createMachine } from 'xstate';
import { useMachine } from '@xstate/svelte';
export const toggleMachine = createMachine({
id: 'toggle',
initial: 'inactive',
states: {
inactive: { on: { TOGGLE: 'active' } },
active: { on: { TOGGLE: 'inactive' } }
}
});
<script>
import { toggleMachine } from './machine.js';
import { useMachine } from '@xstate/svelte';
const { state, send } = useMachine(toggleMachine);
</script>
<button on:click={() => send({ type: 'TOGGLE' })}>
{$state.matches('active') ? 'Turn off' : 'Turn on'}
</button>
Nanostores
Nanostores is a tiny, framework-agnostic state management library with a Svelte adapter. It shines in cross-framework environments and offers computed stores and persistent storage out of the box.
// stores.js
import { atom, computed } from 'nanostores';
import { persistentAtom } from '@nanostores/persistent';
export const cart = persistentAtom('cart', [], {
encode: JSON.stringify,
decode: JSON.parse
});
export const totalPrice = computed(cart, (items) =>
items.reduce((sum, item) => sum + item.price * item.quantity, 0)
);
export function addToCart(product) {
cart.set([...cart.get(), { ...product, quantity: 1 }]);
}
<script>
import { cart, totalPrice, addToCart } from './stores.js';
import { useStore } from '@nanostores/svelte';
const items = useStore(cart);
const total = useStore(totalPrice);
</script>
<ul>
{#each $items as item}
<li>{item.name} - ${item.price}</li>
{/each}
</ul>
<p>Total: ${$total}</p>
<button on:click={() => addToCart({ name: 'Widget', price: 9.99 })}>
Add Widget
</button>
Best Practices
Keep Stores Focused and Composable
Avoid creating a single monolithic store that holds all application state. Instead, split state into domain-specific stores that can be composed together using derived stores.
// Good: separate concerns
export const auth = writable({ user: null, token: null });
export const cart = writable([]);
export const notifications = writable([]);
// Compose when needed
export const userCart = derived(
[auth, cart],
([$auth, $cart]) => $auth.user ? $cart : []
);
Encapsulate Store Logic
Do not expose raw set and update methods unless necessary. Wrap them in named functions that describe the intent, making your code self-documenting and easier to test.
// Good
function createAuthStore() {
const { subscribe, set } = writable({ user: null });
async function login(credentials) {
const user = await api.login(credentials);
set({ user });
}
function logout() {
api.logout();
set({ user: null });
}
return { subscribe, login, logout };
}
Use Context for Scoped State
If a store is only relevant within a subtree (for example, a form wizard or a data table component), use setContext instead of a module-level store. This prevents accidental sharing across unrelated instances and makes your components reusable.
Avoid Overusing Reactive Statements
Reactive statements ($:) are powerful but can lead to confusing code when chained excessively. If you find yourself writing long chains of reactive declarations, consider extracting the logic into a derived store or a plain function.
Type Your Stores
If you are using TypeScript, always type your stores to catch errors at compile time.
import { writable, Writable } from 'svelte/store';
interface User {
id: string;
name: string;
email: string;
}
export const currentUser: Writable<User | null> = writable(null);
Handle Asynchronous State Carefully
For data fetching, combine writable stores with loading and error states to provide a consistent UX.
function createResourceStore(fetcher) {
const data = writable(null);
const loading = writable(false);
const error = writable(null);
async function load(...args) {
loading.set(true);
error.set(null);
try {
const result = await fetcher(...args);
data.set(result);
} catch (err) {
error.set(err.message);
} finally {
loading.set(false);
}
}
return { data, loading, error, load };
}
export const posts = createResourceStore(api.getPosts);
Choosing the Right Approach
- Local component state: Use for UI concerns that never leave the component, like toggle buttons or form field values.
- Writable stores: Use for global or shared state needed by multiple unrelated components.
- Derived stores: Use to compute values from existing stores without duplicating data.
- Context API: Use for state scoped to a subtree, especially in reusable component libraries.
- State machines (XState): Use for complex workflows with many states and transitions, such as multi-step forms or media players.
- Nanostores: Use when you need persistence, cross-framework compatibility, or a smaller bundle footprint.
Conclusion
State management in Svelte strikes a rare balance between simplicity and power. The built-in store primitives โ writable, readable, and derived โ cover the vast majority of real-world use cases without requiring external dependencies. By combining these primitives with the Context API for scoped state and reaching for libraries like XState or Nanostores when complexity demands it, you can build applications that are predictable, maintainable, and performant. The key is to start simple with local state and stores, then introduce more sophisticated patterns only when the problem genuinely calls for them. With thoughtful organization, typed interfaces, and encapsulated logic, your Svelte state management will scale gracefully from a small widget to a full-scale application.