The Store Problem Nobody Talks About
You’ve been building a Svelte app for a few months. Everything feels smooth — until your component tree grows past three levels deep. Suddenly you’re passing stores through props, importing get() in non-component files, and debugging why a writable() update triggered three unexpected re-renders in a child component you forgot existed.
That was my situation on a dashboard project last year. We had a userStore, a settingsStore, and a notificationsStore, all tangled together with derived stores that depended on each other. The logic made sense on paper, but reading that code six weeks later felt like archaeology.
Svelte 5 Runes exist precisely because this pattern breaks down at scale. And they’re not syntax sugar — they swap out the entire reactivity model.
What Was Actually Wrong With Stores
Svelte stores aren’t broken. They work. But they carry real friction that compounds as your app grows:
- The
$subscription prefix is magic syntax — it only works inside.sveltefiles. The moment you need reactive state in a plain.tsutility or a class, you’re callingget()and manually subscribing. - Derived stores are verbose —
derived([storeA, storeB], ([a, b]) => a + b)is functional but noisy for something conceptually simple. - Stores are values, not variables — you can’t just write
userStore.name = 'Alice'. You callupdate()orset(), which breaks the intuitive mental model. - Side effects have no clean home —
$: { someEffect() }in Svelte 4 reactive statements ran on every change but had no teardown mechanism baked in.
None of these kill you individually. Together, though, the friction compounds — and eventually you spend more time managing store boilerplate than actually building features.
Svelte 5 Runes: A Different Foundation
Runes are compiler directives. The Svelte compiler transforms them at build time — and unlike the $ store prefix, they work anywhere JavaScript runs: .svelte files, .svelte.ts utility modules, plain .ts files.
The three you’ll reach for most are $state, $derived, and $effect.
$state: Reactive Variables That Feel Like Variables
Instead of creating a writable store and subscribing to it, you declare reactive state directly:
// Svelte 4 — store approach
import { writable } from 'svelte/store';
const count = writable(0);
// In component: $count to read, count.set(1) or count.update(n => n + 1) to write
// Svelte 5 — rune approach
let count = $state(0);
// Read: count — Write: count = 1 or count++
// That's it.
The assignment syntax feels natural because it is natural. Svelte’s compiler intercepts assignments to $state variables and triggers reactivity automatically. No .set(), no .update().
Objects and arrays are deeply reactive by default:
let user = $state({ name: 'Alice', age: 30 });
// This triggers reactivity — no spread or reassignment needed
user.age = 31;
user.name = 'Bob';
Compare that to Svelte 4, where you sometimes had to reassign the whole object just to get reactive updates to trigger reliably.
$derived: Computed Values Without the Noise
Derived stores in Svelte 4 meant importing derived and wrapping everything in a callback. With Runes, computed state reads like any other expression:
// Svelte 4
import { derived } from 'svelte/store';
const fullName = derived(
[firstName, lastName],
([$first, $last]) => `${$first} ${$last}`
);
// Svelte 5
let firstName = $state('Alice');
let lastName = $state('Smith');
let fullName = $derived(`${firstName} ${lastName}`);
Svelte tracks which $state variables your derived expression reads, then re-runs it only when those change. No manual dependency array. No get() calls.
For more complex derived logic, use $derived.by():
let cart = $state([{ price: 20 }, { price: 35 }, { price: 15 }]);
let total = $derived.by(() => {
return cart.reduce((sum, item) => sum + item.price, 0);
});
$effect: Side Effects With a Teardown Story
This is the one that cleaned up the messiest code in my projects. Svelte 4’s $: { ... } reactive blocks ran side effects but had no cleanup story. Set up an event listener or WebSocket inside one, and you needed separate onDestroy calls plus careful manual coordination to tear it down.
// Svelte 5
let userId = $state(null);
$effect(() => {
if (!userId) return;
const socket = new WebSocket(`wss://api.example.com/user/${userId}`);
socket.onmessage = (e) => handleMessage(e.data);
// Return a cleanup function — runs before the effect re-runs or on destroy
return () => {
socket.close();
};
});
When userId changes, Svelte runs the cleanup first, then re-runs the effect. No onDestroy juggling. The lifecycle lives inside the effect — exactly where it belongs.
Sharing State Across Components: The Runes Replacement for Global Stores
This is probably the most useful pattern to get right when moving away from stores. Global writables were the standard answer for sharing state between unrelated components. With Runes, you create shared reactive state in a .svelte.ts file:
// state/user.svelte.ts
let _user = $state(null);
let _loading = $state(false);
export function getUser() {
return _user;
}
export async function fetchUser(id) {
_loading = true;
try {
_user = await api.getUser(id);
} finally {
_loading = false;
}
}
export function isLoading() {
return _loading;
}
// In any component
import { getUser, fetchUser, isLoading } from './state/user.svelte.ts';
let user = $derived(getUser());
let loading = $derived(isLoading());
One thing to internalize: $state in a module is shared state. One instance per import — same as any module-level variable. Any component that reads it via $derived will re-render when it changes.
In practice, this pattern — module-level $state with exported getter/setter functions — is probably the first thing to get comfortable with when moving to Svelte 5. You get the testability of plain functions, the reactivity of stores, and the readability of regular variables.
Practical Migration: Svelte 4 Store → Svelte 5 Runes
Migrating an existing app? The mapping is straightforward:
writable(initialValue)→$state(initialValue)$storesubscription prefix → remove it, just use the variablestore.set(value)→ direct assignment:value = newValuederived([a, b], fn)→$derived(expression)$: { sideEffect() }→$effect(() => { sideEffect() })- Global writable stores → module-level
$statein.svelte.tsfiles
Readable stores are less common. They map to $state with only the getter exported — no setter in the public API.
When Stores Still Make Sense
Runes don’t retire stores in every situation. Libraries targeting both Svelte 4 and 5 should stick with stores — they’re the cross-compatible API. Same goes for third-party integrations built around the store contract (some animation libraries, for instance).
For application-level state in a Svelte 5 codebase, though? Runes are the cleaner path in almost every scenario I’ve run into.
The Mental Model Shift
The biggest adjustment isn’t syntax. It’s breaking the habit of reaching for a store every time you need reactive state. Stores felt like the “official” reactive primitive because they were, for years.
With Svelte 5, the reactive primitive is just a variable — declared with $state. Once that lands, the rest falls into place: computed values are expressions ($derived), side effects are cleanup-aware functions ($effect), and shared state is a module with reactive variables.
Runes are genuinely closer to plain JavaScript than stores ever were. After migrating a few projects now, I keep waiting to hit some edge case that pushes me back to writable() — it hasn’t happened yet.

