Choosing Between Svelte 5 Runes and Legacy Reactivity for New Components
Decide whether to use Svelte 5 runes ($state, $derived, $effect) or legacy $: statements for new components. Compare trade-offs, see a working runes-only example, and learn how to validate reactivity in mixed codebases.
26 Oct 2026, 15:57 UTC

The Decision You Face
Svelte 5 introduces runes—$state, $derived, $effect, and $bindable—as the new primitive reactivity API. If you're starting a component today on Svelte 5, you must decide whether to adopt runes exclusively, stick with the legacy $: reactive declarations, or mix both. The choice affects TypeScript inference, bundle size, migration effort, and whether reactivity crosses component boundaries correctly.
Constraints That Shape the Choice
- Target runtime: Svelte 5 only (runes are unavailable in Svelte 4).
- Mixed codebase: Svelte 5 runs legacy
$:and runes side-by-side, but they do not auto-sync across component boundaries. - Bundle stability: Runes compile to fine-grained subscriptions; legacy
$:compiles to the same runtime, so bundle size stays comparable. - Explicitness: Runes make derived values and side effects visible in the source; legacy
$:can hide dependency graphs.
Option Comparison
| Dimension | A) Runes Only | B) Legacy Only | C) Mixed (Runes in New Files) |
|---|---|---|---|
| Reactivity granularity | Fine-grained (per-field) | Coarse (whole block re-runs) | Per-file; boundaries need explicit props |
| TypeScript inference | Strong, narrow types | Wider, sometimes any | Strong in rune files, weaker in legacy |
| Side-effect timing | $effect runs after DOM update | $: runs during render | Depends on which API you use |
| Prop binding | $bindable() for two-way | export let (one-way by default) | Legacy children need explicit prop passing |
| Migration cost | Full rewrite per component | Zero, but no new features | Incremental; boundary bugs possible |
| Svelte 4 compatibility | None | Full | Legacy files still work on Svelte 4 |
Trade-offs in Practice
Runes Only
You get predictable reactivity: every $state field is a discrete signal, $derived caches until its dependencies change, and $effect fires after the DOM reflects the new state. TypeScript narrows types because the compiler knows exactly which fields are reactive. The cost is that every component must be written from scratch with runes; you cannot drop a legacy $: block into a rune component and expect it to track rune state automatically.
Legacy Only
Zero rewrite. Existing $: statements and export let props continue to work on Svelte 5. However, you lose fine-grained updates, TypeScript inference stays broad, and side effects run during render (which can cause double-firing with await or DOM measurements). New Svelte 5 features like $bindable and $effect.preload are unavailable.
Mixed Approach
Write new components with runes; leave stable legacy components untouched. The critical rule: rune state does not propagate into legacy children unless passed as a plain prop. If a legacy child mutates an object received from a rune parent, the parent's $derived or $effect will not see the change. Pass primitives or immutable snapshots, or migrate the child to runes.
Concrete Implementation: Runes-Only Counter
The following component demonstrates the three core runes working together. Save it as Counter.svelte in a Svelte 5 project.
<script>
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => {
console.log('doubled changed to', doubled);
});
</script>
<button onclick={() => count += 1}>increment</button>
<p>{doubled}</p>
What Each Line Does
$state(0)creates a reactive cell holding the number0. Mutatingcount(e.g.,count += 1) schedules an update.$derived(count * 2)declares a memoized computation. It re-evaluates only whencountchanges.$effect(() => …)registers a side effect that runs after the DOM updates with the newdoubledvalue. Use this for logging, analytics, or imperative DOM calls—not for computing derived state.
Validation Steps
- Confirm Svelte 5 is installed. Run in your project root:
Requires read access tocat package.json | grep '"svelte"' # Expect: "svelte": "^5.0.0" or similarpackage.json. If the version is 4.x, upgrade first (npm install svelte@latest). - Type-check the component. Run:
Requires Node and thenpx svelte-check # Should exit 0 with no rune-related errorssvelte-checkpackage. Fails if runes are used in a non-rune context (e.g., inside a<script context="module">). - Build and run the app. Start the dev server:
Observe: the paragraph updates tonpm run dev # Open the page, click the button2,4,6… and the console logsdoubled changed to 2,4,6… after each render. This confirms$state,$derived, and$effectare active.
Mixed-Boundary Test (If You Choose Option C)
Create a legacy child LegacyDisplay.svelte:
<script>
export let value;
$: console.log('legacy saw', value);
</script>
<span>{value}</span>
In the rune parent, pass doubled as a prop:
<LegacyDisplay value={doubled} />
Click the button. The legacy child logs each change because it receives a new primitive prop. Now mutate an object inside the legacy child:
<script>
export let obj;
function mutate() { obj.counter += 1; }
</script>
<button onclick={mutate}>mutate</button>
Parent:
let obj = $state({ counter: 0 });
<LegacyMutate obj={obj} />
<p>{obj.counter}</p>
Clicking mutate updates the child's internal state but the parent paragraph does not update. This proves rune state and legacy $: do not auto-sync. Fix: make the child emit an event or migrate it to runes.
Limitations and Gotchas
$effectruns after DOM updates; reading layout inside it is safe, but writing state there triggers another microtask. Avoid state mutations in$effectunless intentional.$derivedis synchronous and pure. Do not call async functions or produce side effects inside it.- Runes are not tree-shakable per-component; the runtime is included once per app.
- Svelte 4 projects cannot use runes. Verify
svelte@5inpackage.jsonbefore adopting.
Quick Decision Checklist
- ✅ New project on Svelte 5 → Runes only.
- ✅ Large existing Svelte 4 codebase, incremental upgrade → Mixed, with strict prop boundaries.
- ✅ Must support Svelte 4 for now → Legacy only until migration window opens.
- ✅ Team unfamiliar with signals/reactivity primitives → Mixed lowers cognitive load; migrate file-by-file.
How to Verify Your Choice Works
After implementing a component with your chosen approach, run the validation steps above. For mixed codebases, add the boundary test to your CI: a small integration test that passes a rune value to a legacy child and asserts the child reacts to prop changes but not to internal mutations. This catches the most common cross-boundary bug before it reaches production.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.