The $state Rune
Declaring reactive state
Section titled “Declaring reactive state”$state creates a reactive value. Read it like a normal variable and reassign it like a normal variable — the compiler makes every read a dependency and every write a trigger.
<script> let count = $state(0);</script>
<button onclick={() => count++}>{count}</button>There are no setters and no special update calls. count++ is a real reassignment, and because the compiler tracks that the button’s text reads count, that one text node updates.
Click Compile below to see this yourself: the same counter, run through the real Svelte compiler in your browser. Look for $.state(0) (the reactive cell count becomes) and $.set(count, ...) (what count++ compiles to) in the output panel.
<script> // English comments: keep this pinned to Svelte 5 runes mode. let count = $state(0);</script>
<button onclick={() => count++}>{count}</button> Deep reactivity via proxies
Section titled “Deep reactivity via proxies”When you pass an object or array to $state, Svelte wraps it in a proxy so that mutations — not just reassignments — are reactive. Pushing to an array or setting a nested property triggers updates.
<script> let todos = $state([{ text: 'learn runes', done: false }]);
function toggle(todo) { todo.done = !todo.done; // mutating a nested property is reactive } function add(text) { todos.push({ text, done: false }); // mutating the array is reactive }</script>This is a real difference from React, where you must produce new objects/arrays. In Svelte, todos.push(...) and todo.done = true just work — the proxy notices the mutation. The reactivity is deep: nested objects and arrays are proxied too.
flowchart LR write["mutate: todo.done = true"] --> proxy["$state proxy notices the change"] proxy --> update["update exactly the dependent DOM"]
Opting out: $state.raw, proven
Section titled “Opting out: $state.raw, proven”Deep proxying costs a little and isn’t always wanted — for large immutable data you only ever replace, use $state.raw. Raw state is reactive on reassignment but not on mutation.
<script> let settings = $state.raw({ theme: 'dark', density: 'cozy' });
function setLightTheme() { // ✅ Reassigning a whole new object IS reactive: settings = { ...settings, theme: 'light' }; }
// ❌ Mutating is NOT tracked with raw state: // settings.theme = 'light'; // no update</script>This isn’t just a claim in the docs — it’s proven, contrasted directly against deep (proxied) $state mutating the exact same way:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';
test('$state.raw: mutation is NOT tracked, only reassignment is', () => { const log: string[] = []; const destroy = $effect.root(() => { let settings = $state.raw({ theme: 'dark', density: 'cozy' });
$effect(() => { log.push(settings.theme); });
flushSync(); expect(log).toEqual(['dark']);
// ❌ mutate a property on raw state — proven to NOT trigger the effect settings.theme = 'light'; flushSync(); expect(log).toEqual(['dark']); // unchanged — mutation was invisible to the effect expect(settings.theme).toBe('light'); // the object itself DID change, just not reactively
// ✅ reassign the whole value — this IS tracked settings = { ...settings, theme: 'blue' }; flushSync(); expect(log).toEqual(['dark', 'blue']); }); destroy();});
test('control: the same mutation on deep (proxied) $state DOES trigger the effect', () => { const log: string[] = []; const destroy = $effect.root(() => { let settings = $state({ theme: 'dark' }); $effect(() => { log.push(settings.theme); }); flushSync(); settings.theme = 'light'; // mutation, not reassignment flushSync(); }); destroy(); expect(log).toEqual(['dark', 'light']); // proxy caught the mutation});✓ $state.raw: mutation is NOT tracked, only reassignment is✓ control: the same mutation on deep (proxied) $state DOES trigger the effect
Test Files 1 passed (1) Tests 2 passed (2)Note the detail in the first test: settings.theme = 'light' genuinely changes the underlying object (settings.theme reads back as 'light' afterwards) — it just doesn’t notify anything, because there’s no proxy watching for it. This is the single most common $state.raw mistake: assuming it behaves like immutable data that rejects mutation, when it actually accepts the mutation silently and simply doesn’t react to it.
Use $state.raw for values you treat immutably (replace wholesale) or large structures where you don’t want the proxy overhead.
Getting a plain copy: $state.snapshot
Section titled “Getting a plain copy: $state.snapshot”Because $state objects are proxies, passing one to code that expects a plain object (a console.log that shows a Proxy, structuredClone, a third-party library) can be surprising. $state.snapshot returns a plain, non-reactive copy.
<script> let user = $state({ name: 'Ada', roles: ['admin'] });
function save() { const plain = $state.snapshot(user); // a normal object, not a proxy api.save(plain); }</script>Module state in .svelte.ts, and the SSR trap
Section titled “Module state in .svelte.ts, and the SSR trap”$state isn’t limited to components — a .svelte.ts (or .svelte.js) module can declare state at module scope, importable by anything:
export const session = $state({ user: null as string | null });Every importer of this module shares the exact same object — there is one session for the whole running process, not one per component instance. Proven: a component that never touches the code path that sets session.user still sees the change, because it’s the same object:
import { flushSync } from 'svelte';import { expect, test } from 'vitest';import { session } from './module-state.svelte.js';
test('module-scope $state is a SINGLE shared instance across every importer — proves the SSR leak risk', () => { const destroy = $effect.root(() => { const seenByComponentA: (string | null)[] = []; $effect(() => { seenByComponentA.push(session.user); }); flushSync();
// "Request 1" sets the user on the shared module state... session.user = 'alice'; flushSync();
// ...and "component A" (which never touched request 1 directly) sees it, // because there is exactly ONE `session` object for the whole process. expect(seenByComponentA).toEqual([null, 'alice']); }); destroy();
// The leak: a second, unrelated "request" importing the same module still // sees 'alice' left over from the previous one, because ES module instances // are cached and shared, not per-request. expect(session.user).toBe('alice');});✓ module-scope $state is a SINGLE shared instance across every importer — proves the SSR leak risk
Test Files 1 passed (1) Tests 1 passed (1)That last assertion is the whole risk in miniature: in a browser-only SPA, a single shared session object is often exactly what you want (that’s the .svelte.js-module-as-store pattern from the Advanced Runes lesson). But the moment this code runs on a server rendering requests for multiple users, that one process-wide object is shared across every request. The official docs are direct about this:
In many cases this is perfectly fine, but there is a risk: if you mutate the state during server-side rendering (which is discouraged, but entirely possible!)… then the data may be accessible by the next user.
— Replacing global state, svelte.dev
The documented fix is context, not a module-level export: context is created fresh per request/render, so it can’t leak between users the way a shared module object can.
Mental Model
Section titled “Mental Model”$state is not “a variable that re-renders things” — it’s a reactive cell: reads register a dependency, writes notify dependents, and for objects/arrays specifically, the cell is a Proxy that also treats property writes as “writes to the cell.” $state.raw is the same cell without the Proxy layer, so only whole-cell reassignment counts as a write — mutating the contents changes the data but never touches the cell itself, so nothing is notified. A module-scope $state is just a cell that happens to live at the top of a file instead of inside a component — which is exactly why it’s shared by everything that imports that file, for as long as the process lives.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
Deep $state (default) | arr.push()/obj.x = y just work, like normal JS | Proxy overhead on every object/array, including ones you never mutate |
$state.raw | No proxy overhead; simple mental model for immutable data | Mutating it is silently ignored — proven above to be a real, easy-to-hit trap |
.svelte.js/.svelte.ts module state | Zero-boilerplate app-wide sharing, no store API | One instance for the whole process — proven to leak across “requests” if mutated during SSR |
$state.snapshot before serializing/logging | Plain object, no Proxy surprises | An extra call to remember to make at every serialization boundary |
Common Misconceptions
Section titled “Common Misconceptions”- “
$state.rawprevents mutation, likeObject.freeze.” Proven false: mutating a raw-state object’s property succeeds silently (settings.themereally does become'light') — it just doesn’t notify any effect or derived watching it. Nothing is frozen; the reactivity is just gone. - “A
.svelte.jsmodule’s exported$stateis scoped per component, like a hook’s local state.” Proven false: it’s one shared object for every importer, for the whole process — proven directly with a component that never wrote the value but still saw the change. - “Server-side rendering is stateless per request, so module state is always safe.” The framework doesn’t make this true automatically — the official docs explicitly warn that mutating shared module state during SSR can leak data to the next user’s request. Context (not a bare module export) is the documented fix when state needs to be request-scoped.
- “
$state.snapshotis only useful forconsole.log.” It matters anywhere a plain, non-Proxy object is required:structuredClone, sending data to aWorker, or passing to a library that doesJSON.stringifyor property-existence checks that behave oddly on a Proxy.
Reading AI-generated code
Section titled “Reading AI-generated code”When you see $state, $state.raw, or module-level reactive state, check for these:
- Is a
$state.rawvalue mutated somewhere (obj.prop = x,arr.push(...)) with the expectation that the UI updates — instead of being reassigned? - Is a
.svelte.ts/.svelte.jsmodule’s exported$statevalue mutated in a code path that could run during server-side rendering — and is that state meant to be per-user? - Does a function pass a
$stateproxy object intoJSON.stringify,structuredClone, or a third-party library that expects a plain object, without calling$state.snapshotfirst? - Is a deeply nested mutation (
todos[0].tags.push(...)) assumed to be reactive when the outer value was declared with$state.raw(which only tracks the top-level reassignment, not nested mutation at all)?
🐛 Spot the bug
function createSettingsStore() {let settings = $state.raw({ theme: 'dark', fontSize: 14 });
return { get settings() { return settings; }, setTheme(theme) { // "Just update the one field that changed" settings.theme = theme; },};}Show the answer
setTheme mutates settings.theme directly on a value declared with $state.raw — but raw state is reactive on reassignment only, not mutation. The mutation genuinely happens (settings.theme really does become the new value if you read it), but nothing that reads settings reactively — an $effect, a $derived, or the template — is notified, because there’s no proxy watching for property writes on raw state. Proven in the probe: mutating settings.theme on $state.raw left an effect’s log unchanged (['dark']), while the exact same mutation on ordinary deep $state correctly re-ran the effect (['dark', 'light']). The fix is to reassign the whole object — settings = { ...settings, theme } — which the same test proves does trigger dependents.