Svelte 5 Runes: When the compiler tracks reactivity directly, there's less room for stores
When working with Svelte 4, you eventually end up writing code like this.
// stores.ts
import { writable } from 'svelte/store';
export const count = writable(0);<!-- Counter.svelte -->
<script>
import { count } from './stores';
</script>
<button on:click={() => $count++}>{$count}</button>The code itself isn't bad. But whether $count is a store auto-subscription, a Rune, or just a variable — you need context to tell them apart. As teams grow, those "wait, what was this $ again?" moments multiply.
The Svelte 5 official release post ("Svelte 5 is alive") published on October 22, 2024 addressed this problem head-on. Runes express reactivity as language-level keywords rather than a runtime concern, with $state, $derived, and $effect at the core. As of 2026, the Svelte official docs recommend Runes first for new code.
This post is written to help those familiar with Svelte 4 stores understand how Runes work and where they diverge from stores at the code level. Even if you have no immediate plans to migrate, understanding why the direction changed will serve you well.
Reactivity Has Moved
Svelte 4: The Compiler Only Sees Inside .svelte
The Svelte 4 compiler could only track reactive variables inside the <script> block of a .svelte file. Declaring let count = 0 made it reactive within that file automatically, but the moment you needed to share that state with another component, you had to cross file boundaries — which is why svelte/store was necessary.
In short: in Svelte 4, state inside .svelte used let, while shared state crossing file boundaries used writable() — two approaches coexisted, each with different syntax and different unsubscription mechanics. "Reactivity" and "file sharing" were separate concepts.
Svelte 5: The Compiler Now Sees .svelte.ts Too
Svelte 5 Runes work across .svelte, .svelte.js, and .svelte.ts files. The compiler statically analyzes these files and generates update code, while also using a signals-based runtime reactivity infrastructure internally. Simple expressions have their dependencies resolved at compile time, while parts that are hard to determine statically — like dynamic property access — are tracked by runtime signals in a hybrid approach.
$state, $derived, and $effect look like functions, but they are actually compiler symbols. You cannot assign them to variables or import them — when the compiler encounters these keywords, it transforms them into code that tracks reads and writes for the corresponding variable.
Three Runes, and Where to Use Them
$state — Declaring Reactive State
Where Svelte 4 made let count = 0 implicitly reactive, Svelte 5 makes it explicit.
<script lang="ts">
let count = $state(0);
let user = $state({ name: 'Alice', score: 0 });
</script>
<button onclick={() => count++}>Click: {count}</button>
<p>{user.name}: {user.score}</p>Wrapping an object with $state causes Svelte to apply a Proxy, providing deep reactivity. Just doing user.score++ updates only the relevant part. Vue's ref() also uses a Proxy, but Svelte diverges in that the compiler generates update code ahead of time.
$derived — Derived Computations, Pure Only
Unlike the $: label, which handled both computations and side effects, $derived is designed to do pure computation only.
<script lang="ts">
let count = $state(0);
let doubled = $derived(count * 2);
let isEven = $derived(count % 2 === 0);
</script>It recomputes automatically when the state it depends on changes. For simple expressions, the compiler determines dependencies statically; for cases with dynamic property access like items[index].name, runtime signals track the actually accessed values.
If you try to mutate another $state inside $derived, the Svelte compiler detects it and emits a warning, directing you to use $derived.by() for complex expressions. That said, the compiler cannot catch every side effect sneaked in via comma operators or function calls, so don't assume "the compiler will stop me" and let your guard down.
When you need multi-stage derivation, declare separate variables for each stage.
<script lang="ts">
let items = $state<string[]>([]);
let filtered = $derived(items.filter(i => i.length > 3));
let sorted = $derived(filtered.toSorted());
let summary = $derived(`${sorted.length} items`);
</script>It looks a bit verbose, but each stage of computation is clearly separated, which makes debugging straightforward.
$effect — Side Effects Go Here
Code with side effects — DOM manipulation, logging, external API calls — belongs in $effect.
<script lang="ts">
let count = $state(0);
$effect(() => {
document.title = `Count: ${count}`;
return () => {
document.title = 'App';
};
});
</script>It runs whenever a reactive state read inside $effect changes. If you return a cleanup function, it is called automatically before the next run or when the component is destroyed.
Honestly, the habit of reaching for $: made the distinction between $derived and $effect confusing at first. A simple rule of thumb:
| Question | Rune to Use |
|---|---|
| Are you computing a value from other state? | $derived |
| Are you running something in response to a state change? | $effect |
| Does it involve side effects? | $effect, without exception |
Sharing State Without Stores — But There's a Trap
Now that Runes work in .svelte.ts files, you can share module-level reactive state without stores. However, there is a trap that catches beginners most often.
Pattern that doesn't work — exporting a primitive directly:
// counter.svelte.ts — this approach breaks reactivity
let count = $state(0);
export function increment() {
count++;
}
export { count };JavaScript modules export primitives by value copy. The moment a component does import { count }, the reactivity link is severed, and calling increment() will not update the UI. This is a common mistake where you quietly lose reactivity while trying to replace a store.
Pattern that works — wrapping in an object or class:
// counter.svelte.ts
class Counter {
count = $state(0);
increment() {
this.count++;
}
}
export const counter = new Counter();<!-- Component.svelte -->
<script lang="ts">
import { counter } from './counter.svelte';
</script>
<button onclick={() => counter.increment()}>{counter.count}</button>When $state lives as a class field, reactivity is preserved through instance property access. Exposing it via a getter works on the same principle.
The $ prefix auto-subscription syntax is gone, and so is the moment of explaining to new team members "this $ is a store subscription, that $ is a Rune" — which is a genuine convenience. That said, the trap above means the impression that "you can just export it" is dangerous.
Trade-offs — It's Not All Upside
Summary of Pros and Cons
| Item | Svelte 4 Stores | Svelte 5 Runes |
|---|---|---|
| Reactivity scope | Inside .svelte only |
Extended to .svelte.ts |
| Cross-file sharing | Requires writable |
Possible via object/class export |
| Explicitness | let is implicitly reactive |
$state declared explicitly |
| Subscription management | $ prefix or subscribe |
Not needed |
| SSR state leak risk | Same caution required with stores | Caution when sharing module-level Runes |
| RxJS integration | Direct integration possible | Wrapping layer required |
| StartStopNotifier | Native support | Difficult to replicate |
Common Mistakes in Practice
1. Sharing module-level state in SSR environments
Declaring let count = $state(0) at the top level of a .svelte.ts file means all requests on the server share the same module instance. State can leak between users.
$state declared inside a component is safe, because the script block re-executes for each component instance, creating state independently per request. When you need to share state within a request scope, use SvelteKit's setContext/getContext combination.
<!-- +layout.svelte — create state in per-request scope -->
<script lang="ts">
import { setContext } from 'svelte';
class Counter {
count = $state(0);
}
setContext('counter', new Counter());
</script><!-- Child.svelte -->
<script lang="ts">
import { getContext } from 'svelte';
const counter = getContext<{ count: number }>('counter');
</script>
<button onclick={() => counter.count++}>{counter.count}</button>This way, the server creates a new instance per request, preventing state from mixing between users.
2. Friction with third-party libraries
When integrating Runes with third-party libraries that expect a store interface (subscribe, set, update), a wrapping layer is required. In such cases, keeping the store as-is may be more pragmatic. Since Svelte 5 allows existing stores and Runes to coexist in the same project, an incremental transition without a big-bang rewrite is realistic.
3. Places where stores are still a better fit — RxJS and StartStopNotifier
The second argument of writable(value, start), the StartStopNotifier, naturally expresses a pattern of opening a resource when the first subscriber attaches and closing it when the last subscriber leaves. This is especially useful for WebSocket connections or RxJS stream bridges. Replicating this lifecycle with Runes requires manually composing $effect cleanup with reference counting, which actually makes the code more complex.
If You're Considering Migration
As of 2026, the Svelte 5 migration guide walks through mechanical conversion via npx sv migrate svelte-5, leaving comments at points that require manual review. You don't have to change everything at once.
To summarize: the claim that "stores are gone" with the move to Runes is only half right. Local state and simple cross-module sharing now belong to $state, but there remain areas where stores are still more natural — such as RxJS stream integration or resource management with StartStopNotifier patterns. Accurately identifying where they diverge is the key to this transition, and once you've made that judgment, operating a codebase where both approaches coexist is no burden at all.
References
- Svelte 5 is alive — Svelte Official Blog
- What are runes? — Svelte Official Docs
- Svelte 5 Migration Guide — Svelte Official Docs
- Introducing runes — Svelte Official Blog
$derived— Svelte Official Docs- Context API — Svelte Official Docs
- Migrating Svelte Stores to Runes — closingtags.com
- Refactoring Svelte stores to $state runes — Loopwerk
- Understanding Svelte 5 Runes: $derived vs $effect — HTML All The Things
- Exploring the magic of runes in Svelte 5 — LogRocket Blog