Web Development · Svelte
Svelte 5 Runes in Production: How the New Reactivity Model Changes the Way You Write Components
Svelte 5's runes replace the old magic variable tracking with explicit reactive primitives. A year into production use, here is what changed, what improved, and what surprised us.
Abhishek Gupta
7 min read
Sponsored
When Svelte 5 shipped in October 2024, the most polarizing change was runes. The old Svelte model — every let variable in a component is implicitly reactive, $: marks a reactive statement — was one of the reasons people liked Svelte in the first place. It felt like magic. You wrote normal JavaScript and the framework made it reactive.
The magic also had a cost. The implicit tracking meant the compiler had to make decisions about reactivity that were sometimes surprising. Debugging “why did this rerender” or “why didn’t this update” often meant reading the compiled output or consulting the documentation on which patterns Svelte did and did not track. The mental model was simple until it wasn’t.
Runes trade some of that simplicity for predictability. After using them in production for a year, the tradeoff is worth it.
The runes, one by one
$state
In Svelte 4:
<script>
let count = 0;
function increment() {
count++;
}
</script>
In Svelte 5 with runes:
<script>
let count = $state(0);
function increment() {
count++;
}
</script>
The change looks minor. The difference matters when you extract logic into a function or a module, where Svelte 4’s implicit tracking broke down. $state creates a reactive binding that works outside the component compiler context too.
For objects and arrays, $state creates a deeply reactive proxy:
<script>
let user = $state({ name: 'Alice', age: 30 });
function birthday() {
user.age++; // this triggers an update; no need for user = { ...user, age: user.age + 1 }
}
</script>
This is a real improvement over Svelte 4, where mutating nested properties required reassigning the whole object or using a special array method pattern.
$derived
$derived replaces the $: reactive declaration for computed values:
<script>
// Svelte 4
$: doubled = count * 2;
// Svelte 5
let doubled = $derived(count * 2);
</script>
More useful with $derived.by for multi-line derivations:
<script>
let items = $state([1, 2, 3, 4, 5]);
let stats = $derived.by(() => {
const sum = items.reduce((a, b) => a + b, 0);
return {
sum,
average: sum / items.length,
max: Math.max(...items),
};
});
</script>
<p>Sum: {stats.sum}, Average: {stats.average.toFixed(1)}</p>
The key rule: $derived is for pure computations. No side effects inside $derived. If you catch yourself writing $derived and then calling a fetch inside it, stop — that belongs in $effect.
$effect
$effect runs a side effect whenever its reactive dependencies change. The dependencies are inferred, not declared:
<script>
let query = $state('');
$effect(() => {
// Runs whenever `query` changes
if (query.length > 2) {
fetchResults(query);
}
});
</script>
The cleanup pattern works the same way as React’s useEffect return:
<script>
let width = $state(window.innerWidth);
$effect(() => {
const handler = () => { width = window.innerWidth; };
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
});
</script>
The most common mistake in Svelte 5 migrations: replacing $: statements that have side effects with $derived instead of $effect. $derived is for values, $effect is for side effects. Blurring this causes hard-to-debug update loops or effects that do not run when expected.
$props
Props are now explicitly declared with $props:
<script>
// Svelte 4
export let name;
export let age = 25;
// Svelte 5
let { name, age = 25 } = $props();
</script>
This might look like a step backward — export let was clean. The advantage is that $props() returns a regular destructured object, and you can use TypeScript more naturally:
<script lang="ts">
interface Props {
name: string;
age?: number;
onUpdate: (name: string) => void;
}
let { name, age = 25, onUpdate }: Props = $props();
</script>
For two-way binding scenarios, $bindable:
<script>
let { value = $bindable() } = $props();
</script>
$inspect
Debugging reactive state used to mean littering console.log in reactive statements. $inspect is a development-mode-only rune that logs whenever a value changes:
<script>
let count = $state(0);
$inspect(count); // logs to console on every change, dev mode only
</script>
Stripped from production builds automatically. Small thing, genuinely useful.
Extracting logic into stores and utilities
One of the practical gains from runes is that reactive logic can live outside the .svelte file. In Svelte 4, you needed writable stores and the $ prefix to make this work. In Svelte 5, you can write plain TypeScript:
// counter.svelte.ts — note the .svelte.ts extension; runes work in these files
export function createCounter(initial = 0) {
let count = $state(initial);
return {
get value() { return count; },
increment() { count++; },
decrement() { count--; },
reset() { count = initial; },
};
}
<script>
import { createCounter } from './counter.svelte.ts';
const counter = createCounter(10);
</script>
<button onclick={counter.increment}>{counter.value}</button>
The .svelte.ts extension tells the Svelte compiler to allow runes in a TypeScript file. This pattern makes logic testable outside the component and reusable across multiple components without the ceremony of a full Svelte store.
The migration from Svelte 4
The svelte-migrate CLI handles most of the mechanical changes:
npx sv migrate svelte-5
What it handles automatically:
- Converting
export letto$props()destructuring - Converting
let x = ...assignments to$state(x) - Converting most
$:statements to$derivedor$effect
What needs manual review:
$:statements that have both a computed value and side effects mixed together- Complex reactive chains where the ordering of
$:statements mattered - Component lifecycle events (the Svelte 4
onMount,onDestroy,beforeUpdate,afterUpdateall still work, but you may want to convert to$effect) - Custom stores that relied on the
Writable<T>pattern fromsvelte/store
For most well-organized Svelte 4 components, the migration is an afternoon of work per hundred components. For components that leaned heavily on complex reactive statement chains, expect to rewrite some logic.
What changed in practice
After running Svelte 5 runes in production on a mid-size SvelteKit app (about 140 components) for the better part of a year, the honest summary:
Better: TypeScript integration improved substantially. Reactive logic extracted into .svelte.ts utilities is testable with Vitest without mounting a component. The explicit $derived vs $effect distinction has prevented several categories of bugs that were common with $: statements. Debugging rerenders is easier because the reactive dependencies are visible in the code rather than inferred from compiler output.
Worse: More to write. The Svelte 4 feel of “just write JavaScript and it works” is somewhat reduced. New team members who come from React or Vue adapt faster (familiar mental model), but people who came specifically because of Svelte’s minimalism sometimes feel like something was lost.
Neutral: Performance is similar. The compiler still produces efficient JavaScript; the main difference is what you write, not what the browser runs.
The runes model makes Svelte feel more like Vue 3’s Composition API or SolidJS signals. Whether that is a good thing depends on what you liked about Svelte in the first place. If you valued the compiler magic, the runes feel like a step back. If you valued the compilation approach but wanted more explicit reactivity, runes are the right direction.
For new projects, start with Svelte 5 and write runes from day one. For existing projects, the svelte-migrate tool makes the path manageable, and the compatibility mode means you can migrate incrementally rather than all at once.
The broader SvelteKit ecosystem has continued to move fast in 2026, and remote functions (introduced in 5.49) are worth reading about separately — they represent a different kind of change to how you structure data fetching in a SvelteKit app.
Frequently asked questions
- What are Svelte 5 runes?
- Runes are compiler-recognized function calls ($state, $derived, $props, $effect, $bindable, $inspect) that explicitly declare reactive intent. In Svelte 4, a variable declared with let in a component was implicitly reactive. In Svelte 5, you declare $state() to opt into reactivity. The compiler still does the transformation, but you are explicit about what is reactive rather than relying on Svelte tracking every assignment.
- Is Svelte 4 code still supported in Svelte 5?
- Yes, via a compatibility mode. Svelte 5 can run Svelte 4 components side by side with runes-based components. The plan is to deprecate the legacy mode eventually, but there is no aggressive removal timeline. The svelte-migrate tool converts most components automatically.
- How does $effect differ from onMount?
- onMount runs once when the component is mounted. $effect runs whenever its reactive dependencies change, including on initial mount. It is closer to React's useEffect with a dependency array, except the dependencies are inferred by Svelte rather than declared explicitly. Use onMount when you need one-time setup (event listeners, third-party library init). Use $effect when the side effect should re-run as state changes.
- Should new projects start with Svelte 5 in 2026?
- Yes, without hesitation. Svelte 4 is still supported but development focus has shifted entirely to Svelte 5. New projects on Svelte 4 will face a migration sooner or later. The migration is manageable but not trivial, so starting on Svelte 5 is the right call for new work.
Sponsored
More from this category
More from Web Development
Web Scraping in 2026: Playwright, Puppeteer, and the Legal Line
Expo Router in 2026: File-Based Navigation That Makes React Native Feel Modern
Fastify in 2026: The Node.js API Framework That Stayed When Everyone Left
Sponsored
Discussion
Join the conversation.
Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.
Sponsored