Runes vs Stores
The same counter, two ways
Section titled “The same counter, two ways”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:
import { writable } from 'svelte/store';export const count = writable(0);<script> import { count } from './counter-store.js';</script><button onclick={() => count.update((n) => n + 1)}>{$count}</button>The runes version:
export const counter = $state({ value: 0 });<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.
Why export an object, not a bare value
Section titled “Why export an object, not a bare value”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:
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:
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.
Side by side
Section titled “Side by side”Store (writable) | Runes (.svelte.js) | |
|---|---|---|
| Read in component | $count | counter.value |
| Update | count.set(x) / count.update(fn) | counter.value = x |
| Outside a component | works anywhere | needs .svelte.js/.svelte.ts |
| The subscribe contract | yes (interop, observables) | no |
| Ceremony | more (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"]
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:
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.
When to use each
Section titled “When to use each”- 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(orcreateSubscriber, 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,updatedas 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/storesis deprecated and subject to be removed in SvelteKit 3.” The replacement,$app/state, is rune-based — same data, no$prefix, and more fine-grained (updatingpage.stateno longer invalidatespage.data).npx sv migrate app-stateautomates most of the mechanical rewrite. Recognizingimport { 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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
$state in a .svelte.js module (object literal or class instance) | No ceremony, same mental model as component state, proven singleton semantics | Must 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 callers | Caller must decide when to call it (and where to store the result) |
writable/readable/derived | The subscribe contract for interop; works in any .js file | More ceremony than runes for plain app state |
fromStore/toStore | Cross the boundary without duplicating state, proven both directions | An extra call at the boundary; still two mental models to hold at once |
Common Misconceptions
Section titled “Common Misconceptions”- “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/toStorecopy 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/storesis 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/storesdeprecated as of SvelteKit 2.12, subject to removal in SvelteKit 3 — it’s a migration target, not a stylistic alternative.
Reading AI-generated code
Section titled “Reading AI-generated code”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$pageread) present in code that otherwise targets a recent SvelteKit — a strong sign of AI training data predating the 2.12$app/statemigration, worth flagging even though it still works today? - Does code duplicate the same data into both a store and a
$statevariable by hand, keeping them in sync manually — instead of usingfromStore/toStoreto bridge the same underlying value?
🐛 Spot the bug
class CurrentUser {name = $state('');isAdmin = $state(false);}
export const currentUser = new CurrentUser();
// Usage in a SvelteKit route:// src/routes/+page.server.tsexport 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.