Skip to content

Runes vs Stores

Before Svelte 5, sharing reactive state across components meant a store. Now you can put $state in a module and export it. Runes work outside components — but only in files with a .svelte.js or .svelte.ts extension, which tells the compiler to enable rune processing.

The store version:

src/lib/counter-store.js
import { writable } from 'svelte/store';
export const count = writable(0);
src/lib/CounterStoreDisplay.svelte
<script>
import { count } from './counter-store.js';
</script>
<button onclick={() => count.update((n) => n + 1)}>{$count}</button>

The runes version:

src/lib/counter-runes.svelte.js
export const counter = $state({ value: 0 });
src/lib/CounterRunesDisplay.svelte
<script>
import { counter } from './counter-runes.svelte.js';
</script>
<button onclick={() => counter.value++}>{counter.value}</button>

No writable, no set/update, no $ prefix — you read and mutate counter.value like a normal object, and it’s reactive everywhere it’s used.

Notice the runes version exports { value: 0 }, not 0. A bare exported let x = $state(0) can’t stay linked across a module boundary — importing it copies the value, losing reactivity. Exporting an object (or a function returning reactive state) keeps everyone pointing at the same reactive proxy, so mutations to counter.value propagate. This is the one gotcha of module-level runes.

A class instance singleton vs a factory, proven

Section titled “A class instance singleton vs a factory, proven”

The same object-vs-bare-value gotcha shows up in a different shape once you reach for a class instead of a plain object literal. A class whose fields are runes is reactive either way — the question is what you do with the instance:

src/lib/class-vs-factory.svelte.ts
export class Counter {
count = $state(0);
double = $derived(this.count * 2);
increment() {
this.count++;
}
}
// Module-level: an INSTANCE of the class, exported directly — a singleton,
// exactly like exporting `$state({...})` directly from a module.
export const singletonCounter = new Counter();
// A factory: calling it creates a brand-new instance every time.
export function createCounter() {
return new Counter();
}

Both are completely valid patterns — they just answer different questions. export const singletonCounter = new Counter() gives everyone who imports it the same object, the same as exporting $state({...}) directly (this module’s whole point above). export function createCounter() gives every caller its own, independent instance — proven directly:

src/lib/class-vs-factory.svelte.test.ts
import { flushSync } from 'svelte';
import { expect, test } from 'vitest';
import { Counter, createCounter, singletonCounter } from './class-vs-factory.svelte.js';
test('a class instance exported directly from module scope is a SHARED singleton (same object for every importer)', () => {
const destroy = $effect.root(() => {
const a = singletonCounter;
const b = singletonCounter; // "two importers" of the same module export
a.increment();
expect(b.count).toBe(1); // b sees a's change: same instance
expect(a).toBe(b);
});
destroy();
});
test('a factory function creates INDEPENDENT instances — no shared state between callers', () => {
const destroy = $effect.root(() => {
const a = createCounter();
const b = createCounter();
a.increment();
expect(a.count).toBe(1);
expect(b.count).toBe(0); // b is unaffected: a separate instance
expect(a).not.toBe(b);
});
destroy();
});
test('class fields declared with runes are reactive exactly like plain $state/$derived variables', () => {
const log: number[] = [];
const destroy = $effect.root(() => {
const counter = createCounter();
$effect(() => {
log.push(counter.double);
});
flushSync();
expect(log).toEqual([0]);
counter.increment();
flushSync();
expect(log).toEqual([0, 2]);
});
destroy();
});
✓ a class instance exported directly from module scope is a SHARED singleton (same object for every importer)
✓ a factory function creates INDEPENDENT instances — no shared state between callers
✓ class fields declared with runes are reactive exactly like plain $state/$derived variables
Test Files 1 passed (1)
Tests 3 passed (3)

The rule is the same one from the section above, just applied to a class instead of an object literal: what’s shared is whatever you export, not whether it’s a class. Export the constructed instance (new Counter()) and every importer shares it — appropriate for genuinely global, single-instance state (the pattern used throughout the Cross-Component State lesson’s module examples). Export a factory (createCounter) and every call site gets its own — appropriate whenever more than one independent instance can exist at once (a form, a modal, a per-item row), the same shape setContext/getContext uses per-subtree in that same lesson.

Store (writable)Runes (.svelte.js)
Read in component$countcounter.value
Updatecount.set(x) / count.update(fn)counter.value = x
Outside a componentworks anywhereneeds .svelte.js/.svelte.ts
The subscribe contractyes (interop, observables)no
Ceremonymore (methods, $)less (plain read/write)
flowchart TB
  q["Sharing reactive state?"] --> interop["Need the subscribe contract
(observables, external push source)?"]
  interop --> yes["Yes: use a store"]
  interop --> no["No: export $state from a .svelte.js module"]
Choosing runes or stores

Bridging the two worlds: fromStore and toStore

Section titled “Bridging the two worlds: fromStore and toStore”

Runes and stores don’t have to be an either/or choice inside one codebase — svelte/store exports fromStore and toStore specifically to cross the boundary in both directions, proven here end to end:

src/lib/from-to-store.svelte.test.ts
import { flushSync } from 'svelte';
import { fromStore, get, toStore, writable } from 'svelte/store';
import { expect, test } from 'vitest';
test('fromStore: bridges a store into a reactive { current } object, both directions', () => {
const store = writable(0);
const destroy = $effect.root(() => {
const count = fromStore(store);
const log: number[] = [];
$effect(() => {
log.push(count.current);
});
flushSync();
expect(log).toEqual([0]);
store.set(1); // store -> rune direction
flushSync();
expect(log).toEqual([0, 1]);
count.current = 2; // rune -> store direction (fromStore's .current is settable)
expect(get(store)).toBe(2);
flushSync();
expect(log).toEqual([0, 1, 2]);
});
destroy();
});
test('toStore: bridges $state into a store, both directions', () => {
const destroy = $effect.root(() => {
let count = $state(0);
const store = toStore(
() => count,
(v) => (count = v),
);
const seen: number[] = [];
const countLog: number[] = [];
const unsub = store.subscribe((v) => seen.push(v));
$effect(() => {
countLog.push(count); // tracked read, proves the rune side too
});
flushSync();
expect(seen).toEqual([0]);
expect(countLog).toEqual([0]);
count = 1; // rune -> store direction
flushSync();
expect(seen).toEqual([0, 1]);
store.set(2); // store -> rune direction
flushSync();
expect(countLog).toEqual([0, 1, 2]);
unsub();
});
destroy();
});
test('toStore with only a getter produces a read-only store (no set/update)', () => {
const destroy = $effect.root(() => {
let count = $state(5);
const store = toStore(() => count);
expect((store as { set?: unknown }).set).toBeUndefined();
expect(get(store)).toBe(5);
count = 6;
flushSync();
expect(get(store)).toBe(6);
});
destroy();
});
✓ fromStore: bridges a store into a reactive { current } object, both directions
✓ toStore: bridges $state into a store, both directions
✓ toStore with only a getter produces a read-only store (no set/update)
Test Files 1 passed (1)
Tests 3 passed (3)

fromStore(store) turns any store into { current } — a rune-friendly shape you can read in a $derived/template and, if the store was writable, assign to directly. toStore(get, set) goes the other way: given a getter (and optionally a setter) closing over $state, it produces a real store with subscribe, so it can be handed to any API that expects the store contract (an existing store-based library, $app/stores-shaped code, a component prop typed as Readable<T>). Neither one duplicates state — both keep a single source of truth and translate reads/writes across the boundary live.

  • Runes (default). Most shared app state — a cart, a theme, a current-user object. Simpler, less boilerplate, and the same mental model as component-local state. A class instance singleton or a factory, proven above, cover the two shapes this takes: shared-by-default vs. independent-per-caller.
  • Stores. When you need the contract: wrapping a WebSocket or other push source with readable (or createSubscriber, from the Built-in Stores lesson), interoperating with RxJS-style observables, or maintaining existing store-based code.
  • Legacy code you’ll recognize, not write. SvelteKit’s own $app/stores — page, navigating, updated as stores with a $ prefix — is a real example of “existing store-based code” you’ll meet in older codebases and AI-generated code trained on it. As of SvelteKit 2.12, the framework’s own docs are direct: “$app/stores is deprecated and subject to be removed in SvelteKit 3.” The replacement, $app/state, is rune-based — same data, no $ prefix, and more fine-grained (updating page.state no longer invalidates page.data). npx sv migrate app-state automates most of the mechanical rewrite. Recognizing import { page } from '$app/stores' as the outdated form — not a bug, but a signal to migrate — is exactly the kind of pattern-recognition this course’s “reading AI-generated code” sections are for.

A module holding $state and a store are two different answers to “where does the single source of truth live, and how do readers find out it changed.” A rune’s answer is structural: the compiler wires readers directly into the same signal graph as the value itself, so there’s no separate notification step to opt into. A store’s answer is behavioral: the object promises to call your callback, and everything else — the $ sugar, fromStore, a hand-rolled subscribe — is a way of fulfilling or consuming that promise. A class instance is just a bundle of runed fields; whether it behaves like a rune-module singleton or a fresh instance per use depends entirely on whether you export the instance or a function that makes one, exactly as it does for a plain object literal.

ChoiceBenefitCost
$state in a .svelte.js module (object literal or class instance)No ceremony, same mental model as component state, proven singleton semanticsMust remember the object-not-bare-value rule; one instance for the whole process
A factory function (createCounter())Independent instance per call, proven — no accidental cross-talk between callersCaller must decide when to call it (and where to store the result)
writable/readable/derivedThe subscribe contract for interop; works in any .js fileMore ceremony than runes for plain app state
fromStore/toStoreCross the boundary without duplicating state, proven both directionsAn extra call at the boundary; still two mental models to hold at once
  • “Exporting a class instance is fundamentally different from exporting $state({...}).” Proven false: both are just “the module exports one object” — a class instance singleton is shared across every importer for exactly the same reason a bare $state({...}) export is.
  • “A factory function like createCounter() shares state with the module-level singleton pattern.” Proven false: createCounter() called twice produced two independent, non-interfering counters — only a direct instance export (or a bare object export) is shared.
  • “fromStore/toStore copy the value once, so the two sides can drift out of sync.” Proven false in both directions: writing on either side of the bridge (the store or the rune) is immediately visible from the other, with no polling or manual re-sync.
  • “$app/stores is just an older name for the same thing as $app/state.” It’s the same underlying data, but the framework’s own docs call $app/stores deprecated as of SvelteKit 2.12, subject to removal in SvelteKit 3 — it’s a migration target, not a stylistic alternative.

When you see module-scope $state, a class instance, a factory function, or $app/stores, check for these:

  • Is a class instance exported directly (export const foo = new Thing()) when the code actually needs a fresh instance per use (a per-row, per-modal, or per-request object) — or, the opposite, is a factory called in a way that accidentally creates a new instance every render when a shared singleton was intended?
  • Does a bare export let x = $state(0) (not an object, not a class instance) get imported elsewhere and expected to update live? Proven in this lesson: only an object/instance export keeps the reactive link.
  • Is import { page } from '$app/stores' (with a $page read) present in code that otherwise targets a recent SvelteKit — a strong sign of AI training data predating the 2.12 $app/state migration, worth flagging even though it still works today?
  • Does code duplicate the same data into both a store and a $state variable by hand, keeping them in sync manually — instead of using fromStore/toStore to bridge the same underlying value?

🐛 Spot the bug

src/lib/current-user.svelte.ts
class CurrentUser {
name = $state('');
isAdmin = $state(false);
}
export const currentUser = new CurrentUser();
// Usage in a SvelteKit route:
// src/routes/+page.server.ts
export async function load({ locals }) {
currentUser.name = locals.user.name;
currentUser.isAdmin = locals.user.isAdmin;
return {};
}
Show the answer

currentUser is a class instance exported directly from the module — a singleton, shared by the whole server process, for exactly the same reason a bare module-level $state({...}) export is (proven in the Cross-Component State lesson, which proves the identical trap for a plain object). Mutating currentUser.name/currentUser.isAdmin inside a load function — server-side, per request — writes into that one shared instance, so a second, concurrent request for a different user can see the first user’s name and admin flag before its own load finishes. Using a class instead of a plain object changes nothing about this risk; what matters is that the instance is a module-level export, not that it’s a class. The fix is the same one proven for module $state: create the user’s state fresh per request — a createCounter()-style factory called inside load/a layout, or setContext/getContext per the pattern in that same lesson — never a directly-exported instance holding per-user data.

How do you use runes for state outside a component?
Why export an object like $state({ value: 0 }) instead of a bare $state(0)?
In the runes version, how do you update shared state?
Two files both import { singletonCounter } from './x' and call .increment() from one of them. Does the other file see the change?
export const singletonCounter = new Counter(); // Counter has $state fields

export function createCounter() {
  return new Counter();
}
What does this log?
const a = createCounter();
const b = createCounter();
a.increment();
console.log(a.count, b.count);
When are stores the better choice over runes?
What does the official SvelteKit documentation say about $app/stores as of version 2.12?