State Management in Svelte 5 with Runes
Svelte 5 state management with runes: use $state, $derived, and $effect, share state across components, and avoid SSR leaks in SvelteKit.
Runes are compiler directives — not runtime imports — that make reactivity explicit and portable: $state declares a reactive cell, $derived computes a value from it, and $effect reacts to changes — together replacing Svelte 4’s implicit $: declarations and writable stores across all three roles. The Svelte docs describe runes as keywords the compiler recognizes, which is why you cannot alias, import, or call them conditionally. Wiring $state into a single component is the easy part. The hard part — the part that breaks in production — is structuring state that several components share without losing reactivity, and scoping that state correctly when SvelteKit renders on the server.
This article treats state management as a spine: local state with $state, computed state with $derived, side effects with $effect, then the part most guides skip — sharing state across files via .svelte.ts modules, scoping it per-request for SSR, and a decision rule for picking the right tool at each step. The throughline: reactivity in Svelte 5 is opt-in per value, and almost every trap comes from where the proxy stops — class instances, destructured bindings, and ESM let exports all sit outside the proxy boundary. Versions referenced here target Svelte 5.x and SvelteKit 2.x.
Key Takeaways
- Any value that can be expressed as a pure function of existing state belongs in
$derived, not in$effect; using$effectto synchronize one piece of state from another is the most common misuse of the rune. - Exporting reassignable
$statefrom a.svelte.tsmodule triggers thestate_invalid_exportcompile error; the two sanctioned fixes are to export a function returning the state, or to export aconstobject or class instance and mutate its properties. - The recommended pattern for app-wide client state is a class with
$statefields exported as aconstinstance — theconstbinding never reassigns, so thestate_invalid_exportproblem disappears. - A
.svelte.tsmodule that declares top-level$statecreates one shared instance per server process, which leaks state between users in SvelteKit SSR; scope per-request state by returning it fromloadand sharing it viasetContext/getContext. - Destructuring a
$stateobject captures the value at destructure time, not a reactive binding — read through the proxy or pass a getter to preserve reactivity.
What is $state and how does local reactive state work?
$state declares a reactive cell whose reads register dependencies and whose writes schedule updates; for primitives you assign and reassign normally, and for objects and arrays Svelte returns a deep proxy so direct mutation is tracked. Per the $state documentation, passing a plain object or array makes it deeply reactive — you mutate it in place and dependent UI updates.
<script lang="ts">
let count = $state(0);
let todos = $state([{ id: 1, text: 'Learn runes', done: false }]);
function addTodo() {
todos.push({ id: Date.now(), text: 'New todo', done: false }); // tracked
}
function toggle(id: number) {
const todo = todos.find((t) => t.id === id);
if (todo) todo.done = !todo.done; // property assignment is tracked
}
</script>
<button onclick={() => count++}>Clicked {count} times</button>
On a deeply reactive $state object or array, both push() and direct property assignment work because the proxy intercepts mutations at any depth. The docs note one boundary: proxification stops at class instances. A plain object or array becomes deeply reactive; a class instance does not, unless its fields are themselves declared with $state.
For large, flat data you replace wholesale rather than mutate, use $state.raw, which tracks reassignment only — not mutation.
<script lang="ts">
let payload = $state.raw(largeApiResponse);
// payload.foo = 'x'; // no update — mutation is not tracked
payload = { ...payload, foo: 'x' }; // update fires — reassignment is tracked
</script>
$state.raw skips proxy creation, so it avoids the per-property descriptor overhead of deep proxying on large flat objects. Reach for it when values are large, effectively immutable, and replaced as a unit — parsed JSON responses, configuration blobs, lookup tables. Otherwise, default to $state.
How do $derived and $derived.by handle computed state?
Discover how at OpenReplay.com.
$derived declares a value computed from reactive state that updates automatically when its dependencies change, replacing Svelte 4’s $: reactive declarations. Per the $derived documentation, it is recalculated from its dependencies when next read after any of them changes. Since Svelte 5.25 a non-const derived can also be temporarily reassigned to override its value — useful for optimistic UI — after which it reverts to its computed value when a dependency next changes.
<script lang="ts">
let count = $state(0);
let doubled = $derived(count * 2);
let isEven = $derived(count % 2 === 0);
</script>
For computations that need loops, conditionals, or multiple statements, use $derived.by, which takes a function instead of an expression:
<script lang="ts">
let items = $state([{ price: 10, quantity: 2 }]);
let summary = $derived.by(() => {
let total = 0;
let count = 0;
for (const item of items) {
total += item.price * item.quantity;
count += item.quantity;
}
return { total, count };
});
</script>
The rule that prevents most reactivity bugs: any value that can be expressed as a pure function of existing state belongs in $derived, not in $effect. Using $effect to synchronize one piece of state from another introduces a redundant reactive cycle and is the most common misuse of the rune. If you find yourself writing an effect that sets state from other state, replace it with $derived.
When should you use $effect?
$effect is for side effects only — subscriptions, logging, manual DOM work, and persistence — never for deriving values. Per the $effect documentation, effects run after the DOM is updated, automatically track the reactive values read inside them, re-run when those values change, and run only in the browser — never during SSR. There is no dependency array; Svelte detects dependencies from what you read.
<script lang="ts">
let count = $state(0);
$effect(() => {
const id = setInterval(() => console.log('tick', count), 1000);
return () => clearInterval(id); // cleanup runs before re-run and on destroy
});
</script>
The returned function is a cleanup callback — it runs before the effect re-executes and when the component is destroyed, which is where you tear down intervals, listeners, and subscriptions. Because effects skip SSR entirely, do not rely on them to produce server-rendered output; that work belongs in load functions or $derived.
The single misuse worth memorizing: do not use $effect to copy state. Writing $effect(() => { fullName = `${first} ${last}` }) creates a write-back loop where a $derived would be both correct and simpler:
<script lang="ts">
let first = $state('Ada');
let last = $state('Lovelace');
let fullName = $derived(`${first} ${last}`); // correct — no effect needed
</script>
How do you share state across components in Svelte 5?
To share reactive state across files, put it in a .svelte.ts module — but you cannot export a reassignable $state binding directly. The intuitive pattern fails:
// src/lib/counter.svelte.ts
export let count = $state(0);
export function increment() {
count += 1;
}
Exporting a reassignable $state binding from a module like this triggers the documented state_invalid_export compile error, with the verbatim message: “Cannot export state from a module if it is reassigned. Either export a function returning the state value or only mutate the state value’s properties.” The trigger is the reassignment (count += 1), not the import itself — the error fires because the exported $state is reassigned, which an ESM let export cannot do safely across the module boundary. There are two sanctioned fixes.
Fix 1 — export a const object and mutate its properties. The binding never reassigns; only its properties change, so every importer reads the same proxy.
// src/lib/counter.svelte.ts
export const counter = $state({ count: 0 });
export function increment() {
counter.count += 1; // property mutation, not rebinding
}
Fix 2 — keep the state file-local and export getters. This keeps the cell private and prevents external reassignment.
// src/lib/counter.svelte.ts
let count = $state(0);
export function getCount() {
return count;
}
export function increment() {
count += 1;
}
The recommended pattern for app-wide client state is the cleanest version of Fix 1: a class with $state fields exported as a const instance. The const binding never reassigns, so the state_invalid_export problem disappears, and every component importing the instance reads from the same reactive proxy.
// src/lib/todo-store.svelte.ts
class Todo {
done = $state(false);
text = $state('');
constructor(text: string) {
this.text = text;
}
}
class TodoStore {
items = $state<Todo[]>([]);
filter = $state<'all' | 'active' | 'done'>('all');
get visible(): Todo[] {
if (this.filter === 'all') return this.items;
const wantDone = this.filter === 'done';
return this.items.filter((t) => t.done === wantDone);
}
add(text: string) {
this.items.push(new Todo(text));
}
remove(todo: Todo) {
this.items = this.items.filter((t) => t !== todo);
}
}
export const todoStore = new TodoStore();
Any component that imports todoStore reads and mutates the same reactive instance — no context plumbing, no subscription boilerplate. Note the $state<Todo[]>([]) type parameter: TypeScript infers types from initializers, but you pass an explicit parameter for empty arrays, empty objects, or unions wider than the initial value. This pattern carries one important caveat for SSR, covered below.
How does $state behave inside classes?
$state works as a class field, and the compiler transforms each field into a prototype get/set accessor pair backed by a private signal. The $state classes documentation describes this transformation, and it has two consequences worth knowing: because the accessors live on the prototype rather than the instance, Object.keys(instance) does not list reactive fields, and spreading { ...instance } omits them.
class Todo {
done = $state(false);
text = $state('');
constructor(text: string) {
this.text = text;
}
toggle() {
this.done = !this.done;
}
}
The trap that catches everyone is this-binding in event handlers. Passing a method reference detaches it from the instance, and the documentation covers this case directly:
<!-- this === the <button>, not the Todo instance — broken -->
<button onclick={todo.toggle}>toggle</button>
<!-- this === todo — works -->
<button onclick={() => todo.toggle()}>toggle</button>
Because reads and writes traverse prototype accessors, the method needs the correct this. Two fixes keep it bound: wrap the call in an arrow function at the call site (() => todo.toggle()), or define the method as an arrow-function field so it closes over this:
class Todo {
done = $state(false);
toggle = () => {
this.done = !this.done; // arrow field — `this` is bound permanently
};
}
Use the arrow-field form when you intend to pass the method around as a reference; use a normal method when you always call it as todo.toggle().
What are the main reactivity gotchas with runes?
Most runes bugs trace back to a single rule: reactivity is opt-in per value, and the proxy stops at class instances, destructured bindings, and ESM exports. Three gotchas account for the majority of lost reactivity.
Destructuring captures a value, not a binding. Destructuring a $state object reads the value at destructure time; it does not create a reactive reference.
const user = $state({ name: 'Ada', age: 36 });
const { name } = user;
user.name = 'Grace'; // user.name updates
console.log(name); // still 'Ada' — captured at destructure time
To preserve reactivity, read through the original proxy (user.name) or pass a getter function (() => user.name) that closes over the proxy and re-reads on each call. This is the fix to reach for whenever a value “stops updating” after you refactored it into a variable.
Native Map, Set, Date, and URL are class instances, so the proxy stops at them. Use the reactive equivalents from svelte/reactivity, which track reads on .size, .get(), .has(), and iteration: SvelteMap, SvelteSet, SvelteDate, SvelteURL, and SvelteURLSearchParams.
import { SvelteMap } from 'svelte/reactivity';
const cache = new SvelteMap<string, number>();
cache.set('a', 1); // reactive
The documentation notes one caveat: values stored inside a reactive Map or Set are not made deeply reactive. If you store a plain object in a SvelteMap and expect to mutate it reactively, wrap that object with $state first.
Proxies cannot cross certain API boundaries. structuredClone, postMessage, and some serializers reject proxies. Use $state.snapshot to take a plain, static copy at the boundary:
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify($state.snapshot(user)),
});
Use $state.snapshot only at these boundaries, not throughout your code — the proxy is what you want everywhere else.
When should you use module-global state versus Svelte context?
Module-global state is safe for client-only apps but unsafe for SSR; request-scoped state belongs in a load function and is shared through Svelte’s context API. A .svelte.ts module that declares $state at the top level creates a single shared instance per server process. On the client that is exactly what you want. In SvelteKit SSR it is a request-leakage risk: the SvelteKit state-management docs state that servers are long-lived and shared across users, that you must not store per-user data in shared module variables, and give the canonical example of one user’s secret leaking into another user’s render.
The leak looks like this — a module store mutated during a server render is visible to the next request hitting the same process:
// src/lib/user.svelte.ts — DANGEROUS in SSR
export const currentUser = $state({ name: '' });
// +page.server.ts — sets shared state during SSR
import { currentUser } from '$lib/user.svelte';
export function load({ locals }) {
currentUser.name = locals.user.name; // leaks across requests
}
The canonical fix is to return per-request data from load and share it via setContext/getContext, which scopes the value to a single component tree — and therefore a single request — instead of a process-wide module.
// +layout.server.ts
export function load({ locals }) {
return { user: locals.user }; // per-request data
}
<!-- +layout.svelte -->
<script lang="ts">
import { setContext } from 'svelte';
let { data } = $props();
setContext('user', data.user); // scoped to this request's component tree
</script>
In production SvelteKit apps, improperly scoped module-level state shows up in session replays as a user briefly seeing another user’s data or stale values on initial load — the visible symptom of state that should have been request-scoped by returning per-request data from load and sharing it via context, rather than held in a module-global. Session replays of these implementations surface the bug because they capture the initial server-rendered DOM, not just the post-hydration state.
The decision rule that ties the whole spine together:
Use component-local
$statefor values owned by one component;$derivedfor anything computed from that state; a module-level class store (export const store = new Store()) for app-wide client state; and per-request data returned fromloadand shared viasetContext/getContextfor request-scoped or SSR state that must not leak between users.
Props plumbing — $props, $bindable, and the $inspect debug helper — sits outside this state-management spine; treat them as component-interface and debugging tools rather than state containers.
Migrating from Svelte 4 stores to runes
Most Svelte 4 reactivity patterns map directly onto runes, including the shared-state case that writable stores used to own. The table below covers the translations intermediate developers hit most often; the Svelte 5 migration guide covers the full surface.
| Svelte 4 | Svelte 5 runes |
|---|---|
let count = 0; | let count = $state(0); |
$: doubled = count * 2; | let doubled = $derived(count * 2); |
$: console.log(count); | $effect(() => console.log(count)); |
export let name = 'world'; | let { name = 'world' } = $props(); |
const count = writable(0); $count++ | let count = $state(0); count++ |
writable store shared across components | class store exported as export const store = new Store() |
| store subscribed in SSR layout for per-user data | load returns data → setContext/getContext |
The last two rows are the ones most guides omit: a writable store shared across an app becomes a class instance exported as a const, and a per-user store in an SSR context becomes per-request data returned from load and threaded through context.
Runes make the reactivity model explicit, but the leverage is in the architecture: keep state as local as it can be, promote it to a module-level class store only when several components genuinely share it, and scope it through context the moment SSR enters the picture. Audit your .svelte.ts modules for top-level $state that participates in server rendering — that single check catches the highest-severity class of state bug in SvelteKit.
FAQs
What is the difference between $state.raw and $state in Svelte 5?
$state returns a deep proxy that tracks mutation at any depth, so push() and property assignment trigger updates; $state.raw skips proxy creation and tracks reassignment only, meaning in-place mutation is ignored and you must replace the value wholesale to trigger an update. Use $state.raw for large, effectively immutable data replaced as a unit, such as parsed JSON responses, configuration blobs, or lookup tables, where skipping per-property proxy overhead matters. Default to $state otherwise.
Why does my $state value stop updating after I destructure it?
Destructuring a $state object reads the value at destructure time and creates a plain, non-reactive variable; it does not create a reactive binding to the proxy. After const name = user.name, later mutating user.name updates the proxy but leaves the destructured name frozen at its original value. To keep reactivity, read through the original proxy with user.name at the point of use, or pass a getter function such as () => user.name that closes over the proxy and re-reads on each call.
Do $effect callbacks run during SSR in SvelteKit?
No. $effect callbacks run only in the browser and never during server-side rendering. Effects run after the DOM is updated and re-run when the reactive values they read change, which means they cannot contribute to server-rendered HTML. Do not rely on $effect to produce output during SSR; place that work in a load function or in $derived. Effects are for browser-only side effects such as subscriptions, logging, manual DOM work, intervals, and persistence, with an optional returned cleanup callback.
Are values stored inside a SvelteMap or SvelteSet deeply reactive?
No. SvelteMap and SvelteSet from svelte/reactivity track reads on .size, .get(), .has(), and iteration, and react to entries being added or removed, but the values stored inside them are not made deeply reactive. If you store a plain object in a SvelteMap and expect mutating its properties to trigger updates, wrap that object with $state first so the inner object becomes a reactive proxy. The reactive collection tracks structure, not the internal state of arbitrary stored values.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k