Skip to content

The $state Rune

$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>

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"]
A $state object is a reactive proxy

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:

src/lib/state-raw.svelte.test.ts
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.

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:

src/lib/module-state.svelte.ts
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:

src/lib/module-state.svelte.test.ts
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.

$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.

ChoiceBenefitCost
Deep $state (default)arr.push()/obj.x = y just work, like normal JSProxy overhead on every object/array, including ones you never mutate
$state.rawNo proxy overhead; simple mental model for immutable dataMutating it is silently ignored — proven above to be a real, easy-to-hit trap
.svelte.js/.svelte.ts module stateZero-boilerplate app-wide sharing, no store APIOne instance for the whole process — proven to leak across “requests” if mutated during SSR
$state.snapshot before serializing/loggingPlain object, no Proxy surprisesAn extra call to remember to make at every serialization boundary
  • “$state.raw prevents mutation, like Object.freeze.” Proven false: mutating a raw-state object’s property succeeds silently (settings.theme really does become 'light') — it just doesn’t notify any effect or derived watching it. Nothing is frozen; the reactivity is just gone.
  • “A .svelte.js module’s exported $state is 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.snapshot is only useful for console.log.” It matters anywhere a plain, non-Proxy object is required: structuredClone, sending data to a Worker, or passing to a library that does JSON.stringify or property-existence checks that behave oddly on a Proxy.

When you see $state, $state.raw, or module-level reactive state, check for these:

  • Is a $state.raw value mutated somewhere (obj.prop = x, arr.push(...)) with the expectation that the UI updates — instead of being reassigned?
  • Is a .svelte.ts/.svelte.js module’s exported $state value 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 $state proxy object into JSON.stringify, structuredClone, or a third-party library that expects a plain object, without calling $state.snapshot first?
  • 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.

How do you update $state in Svelte 5?
Why is todos.push(newTodo) reactive when todos is $state([])?
Does the effect log again after settings.theme = 'light'?
let settings = $state.raw({ theme: 'dark' });
$effect(() => console.log(settings.theme));
settings.theme = 'light'; // mutate, not reassign
What does $state.snapshot(user) return?
Two unrelated components both import { session } from './session.svelte.ts'. What is true?
// src/lib/session.svelte.ts
export const session = $state({ user: null });
Why do the official Svelte docs warn against mutating shared .svelte.js module state during server-side rendering?