Svelte 5 $state rune - deep dive
By Justin Ahinon.
Last updated
The $state rune is the one you'll type more than any other. It wraps a value in a reactive cell, and the compiler rewrites every read and write so the rest of your code looks like plain JavaScript.
That illusion holds up for most cases. But "looks like plain JavaScript" has edges: the proxy stops at class instances, destructuring detaches the read, and ES module exports do not survive the rewrite. This post is the deep version of each of those.
If you want the full rune tour first, read the Svelte 5 runes guide. This post assumes you already know the basics and focuses on $state end-to-end.
What $state does
$state(value) declares a reactive cell. Reads register a dependency, writes schedule updates. The syntax is a function call, but $state is a compiler keyword, not a runtime import: you can't alias it, pass it around, or call it conditionally.
<script>
let count = $state(0);
</script>
<button onclick={() => count++}>clicks: {count}</button>
The compiler rewrites count to a signal, reads to $.get(count), and the increment to $.update(count). There is no .value, no .set(), no subscription. The mental model is simple: $state is a signal under the surface, and the variable is the window onto it.
You have already seen the compiled output for this counter case in the cornerstone. The rewrites get more involved once values get richer.
Deep reactivity and where the proxy stops
Hand $state a plain object or array, and what you get back is a deep Proxy. Mutations at any depth trigger updates:
const todos = $state([{ done: false, text: 'write post' }]);
todos[0].done = true; // triggers update
todos.push({ done: false, text: 'ship' }); // new entry is also proxified
todos[1].text = 'ship it'; // and its fields are reactive too
Proxification recurses until it hits a class instance or an Object.create-based object. That boundary is deliberate: Svelte cannot wrap arbitrary class semantics safely, so it does not try. Plain objects and arrays are deeply reactive; class instances are not.
Reactive Map, Set, Date, and URL
Native Map, Set, Date, and URL instances are class instances, so the deep proxy stops at their boundary. For reactive equivalents, reach for the wrappers in svelte/reactivity:
import { SvelteMap, SvelteSet, SvelteDate, SvelteURL } from 'svelte/reactivity';
const tags = new SvelteSet<string>();
const cache = new SvelteMap<string, User>();
const now = new SvelteDate();
They extend the native classes and track reads on .size, .get(), .has(), and iteration. Values stored inside them are not deeply reactive on their own: if you put a plain object into a SvelteMap, mutations to that object will not fire updates unless you wrapped it with $state first.
Destructuring breaks the read, not the data
Destructuring a $state object does not give you reactive bindings. It gives you plain values at the moment of destructure:
const user = $state({ name: 'Justin', age: 30 });
const { name } = user;
user.name = 'J'; // user.name updates
console.log(name); // still 'Justin' — captured at destructure time
The data is still reactive; the local binding is not. Two ways out: read through the original proxy (user.name) everywhere you need the live value, or pass a getter function (() => user.name) when you need to hand a reactive read to another function. The getter closes over the proxy, so every call re-reads through it.
$state in classes
$state works as a class field. This is the officially blessed way to build reactive objects that behave like real instances rather than bags of properties:
class Todo {
done = $state(false);
text = $state('');
constructor(text: string) {
this.text = text;
}
toggle() {
this.done = !this.done;
}
}
const todo = new Todo('write post');
The compiler rewrites each $state field into a get/set pair on the prototype, backed by a private signal. That is what lets you read and write todo.done without a .value, but it has three downstream consequences.
A $state class field is a getter/setter pair on the prototype, not a data property on the instance.
What the compiler does
Here is the compiled output for the class above (trimmed for readability):
import * as $ from 'svelte/internal/client';
class Todo {
#done = $.state(false);
get done() {
return $.get(this.#done);
}
set done(value) {
$.set(this.#done, value, true);
}
#text = $.state('');
get text() {
return $.get(this.#text);
}
set text(value) {
$.set(this.#text, value, true);
}
constructor(text) {
this.text = text;
}
toggle() {
this.done = !this.done;
}
}
const todo = new Todo('write post');
The fields are no longer own properties of the instance. They live on the prototype as accessors, backed by private #-prefixed signals. That is why:
Object.keys(todo)will not listdoneortext: accessor properties on a prototype are not enumerable own properties.Spreading
{ ...todo }drops the reactive fields for the same reason. If you need a plain snapshot, see$state.snapshotbelow.Reads and writes go through the prototype accessors, so
thismatters. Passing a method reference unbinds it.
The this-binding trap
Because done lives on the prototype, writing to it requires the right this. Handing a method reference directly to an event handler unbinds it:
<!-- this === the <button>, not the Todo instance -->
<button onclick={todo.toggle}>toggle</button>
<!-- this === todo, works -->
<button onclick={() => todo.toggle()}>toggle</button>
Same rule as any JavaScript method reference. Either wrap the call in an arrow function, bind in the constructor (this.toggle = this.toggle.bind(this)), or define the method as an arrow field (toggle = () => { ... }). I reach for the arrow-wrap in templates; the arrow field in classes I expect to expose methods as values.
Typing class fields
TypeScript infers the type from the initializer, so you rarely need to annotate:
class Todo {
done = $state(false); // boolean
text = $state<string>(''); // explicit string — usually redundant
tags = $state<string[]>([]); // worth spelling out for arrays
status = $state<'idle' | 'loading' | 'error'>('idle');
}
Pass the type parameter directly to $state when you need a union wider than the initial value, or when the initial value is an empty array or object that TypeScript would otherwise narrow too aggressively.
$state.raw — when to skip the proxy
Deep proxies are not free. Every property access goes through a trap, and for large, flat data you rarely mutate, that cost shows up. $state.raw gives you reactivity on reassignment only:
let payload = $state.raw(largeApiResponse);
payload.foo = 'x'; // no update — mutation is not tracked
payload = { ...payload, foo: 'x' }; // update fires — reassignment is tracked
Rule of thumb: reach for $state.raw when the value is large, arrives from outside your code, and you treat it as immutable: parsed JSON responses, config objects, pre-computed lookup tables. Everything else, plain $state is fine.
Raw state can still contain reactive state. An array declared with $state.raw can hold objects declared with $state, and the inner objects stay reactive. The raw wrapper only opts out its own proxy, not the contents.
Default to $state; reach for $state.raw only when the value is large, effectively immutable, and replaced wholesale.
$state.snapshot and $state.eager
Two escape hatches for specific scenarios. $state.snapshot is for APIs that can't accept proxies; $state.eager is for a visible scheduling lag you need to eliminate.
$state.snapshot for APIs that choke on proxies
Some APIs don't play well with Proxies: structuredClone, some JSON serializers, postMessage, anything that walks own properties. $state.snapshot(value) returns a plain, static copy:
await fetch('/api/save', {
method: 'POST',
body: JSON.stringify($state.snapshot(user))
});
Use it at the boundary, not throughout your code. The snapshot is a detached copy: mutating it will not update the original, and reading it does not register a dependency.
$state.eager for user-input responsiveness
State updates that happen inside an await are batched by default. Usually fine, occasionally visible as a one-frame lag on a user action (an aria-current attribute that flashes, say). $state.eager(value) forces a synchronous read:
<a href="/" aria-current={$state.eager(pathname) === '/' ? 'page' : null}>home</a>
Reach for it only when you can see the delay. The cornerstone has more on the scheduling model behind this.
Sharing state across files
Here is the one that bites most people. You'd expect this to work:
// src/lib/counter.svelte.ts
export let count = $state(0);
export function increment() {
count += 1;
}
It doesn't. The compiler needs to rewrite count += 1 into signal set-calls, but a let export is rebindable across the ES module boundary, and importers have no way to know they should go through the rewritten setter. Svelte catches this at compile time with a clear error. Two patterns that do work:
Option 1 — export an object and mutate its properties
// src/lib/counter.svelte.ts
export const counter = $state({ count: 0 });
export function increment() {
counter.count += 1;
}
The binding (counter) never reassigns; only its properties change. Imports see the same proxy and observe the mutations. This is the pattern I reach for first for module-level state.
Option 2 — keep the state file-local, export getters
// src/lib/counter.svelte.ts
let count = $state(0);
export function getCount() {
return count;
}
export function increment() {
count += 1;
}
Useful when you want to prevent external code from reassigning the value at all, or when the state is an implementation detail you want to keep private to the module.
Putting it together: a class-based store
Classes compose well with cross-module sharing because the exported binding is a const reference to the instance. Reassignment is not on the table. Here is the pattern I use for real app state:
// 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() {
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 from the same reactive instance:
<!-- src/routes/+page.svelte -->
<script lang="ts">
import { todoStore } from '$lib/todo-store.svelte';
let draft = $state('');
</script>
<form onsubmit={(e) => { e.preventDefault(); todoStore.add(draft); draft = ''; }}>
<input bind:value={draft} />
<button type="submit">add</button>
</form>
<ul>
{#each todoStore.visible as todo}
<li>
<input type="checkbox" bind:checked={todo.done} />
{todo.text}
</li>
{/each}
</ul>
The pattern is: mutate through the class, rebind nothing, let the proxy do the rest. The same store works in a route, a layout, or a deeply nested component without any context plumbing. When you need request-scoped state on the server instead, wrap the instance in a context. That is a separate topic.
FAQ
Is $state deeply reactive?
Yes for plain objects and arrays: mutating any nested property or array entry triggers updates. No for class instances: only fields declared with $state inside the class are reactive. No for $state.raw: only reassignment fires updates, and mutation is ignored.
Why doesn't my destructured value update?
Destructuring reads the value once and stores it in a new binding. The new binding does not know about the signal. Read through the original object (user.name) or pass a getter function (() => user.name) when you need a reactive read somewhere else.
Can I export $state from a .svelte.ts file?
Not directly. export let count = $state(0) is a compile error. Export a const object (or class instance) and mutate its properties, or keep the state file-local and export getter/setter functions. See the cross-module section above.
When should I use $state.raw vs $state?
Default to $state. Use $state.raw when the value is large (thousands of entries or more), effectively immutable from your code, and you only ever reassign it. Typical fits: parsed API responses, config blobs, pre-computed lookup tables.
Does $state work in regular .ts files?
No. Runes only work in .svelte, .svelte.js, and .svelte.ts files. The extension is what tells the compiler to apply the rune rewrites. A plain .ts file will not know what $state is.
Wrapping up
Reactivity in Svelte 5 is opt-in, per value. $state is the on-switch, and most of its traps are downstream consequences of that opt-in being built on a proxy. Know where the proxy stops (class instances, destructured bindings, ESM exports), and the rest is just JavaScript.
Next up in this series: deep-dives on $derived for values that follow from state, and $effect for when state needs to touch the outside world.
Happy coding!