Skip to content

· 7 min read

Svelte 5 Runes: A Year Later

After a full year of building with Svelte 5's runes syntax in production, here's what worked, what surprised me, and what I'd tell someone starting today.

By Tanner Goins

MacBook Pro in low light showing HTML code in the PhpStorm editor with a dark theme

It’s been about a year since I rebuilt this site with Svelte 5 and later redesigned it from scratch with a custom design system. That’s a full year of living with runes ($state, $derived, $effect, $props) in a real production codebase.

I’m now firmly in the camp that runes are a better model for reactivity than what Svelte 4 offered. But getting here required unlearning old habits and discovering a few patterns that the docs don’t emphasize enough.

Here’s the honest retrospective.


What Runes Got Right

Explicit Reactivity Is Better Than Magic

In Svelte 4, reactivity was implicit. Declare a variable with let, and it’s reactive. Use $: to create a derived value. The compiler figured out the dependency graph behind the scenes.

It was elegant for simple cases and confusing for complex ones. When things broke, you couldn’t always tell why a value wasn’t updating, because the reactivity system was invisible.

Runes make everything explicit:

<script>
  let count = $state(0);
  let doubled = $derived(count * 2);

  function increment() {
    count++;
  }
</script>

<button onclick={increment}>{doubled}</button>

$state() declares a reactive variable. $derived() declares a computed value. There’s no guessing about what’s reactive and what isn’t. When something doesn’t update, you can trace the dependency chain yourself because it’s right there in the code.

After a year, I can say this: I debug reactivity issues much faster with runes. The explicit model means I spend less time wondering “is this reactive?” and more time solving actual problems.

$props() Eliminates a Whole Category of Bugs

In Svelte 4, component props were declared with export let. This worked, but it had quirks: default values behaved differently than you’d expect, and there was no clean way to type the full props interface.

$props() is cleaner:

<script lang="ts">
  let {
    title,
    variant = 'primary',
    onclick,
    children
  }: {
    title: string;
    variant?: 'primary' | 'secondary';
    onclick?: () => void;
    children: Snippet;
  } = $props();
</script>

Destructuring with types and defaults in one place. No more export let. No more wondering if a prop was passed or fell through to a default. The TypeScript integration is especially good. My IDE catches prop errors at the call site, not at runtime.

Snippets > Slots

The old slot system worked but was limited. Named slots were clunky, and passing data back from a slot required awkward let: directives.

Snippets with {#snippet} and {@render} are more flexible and more composable:

{#snippet header(title)}
  <h2 class="text-xl font-bold">{title}</h2>
{/snippet}

<Card {header} title="My Card">
  <p>Card content here.</p>
</Card>

I use snippets extensively in my design system components. They compose naturally, they’re typed, and they make it clear what content goes where.


What Surprised Me

$effect() Is Easy to Overuse

When I first migrated to runes, I reached for $effect() constantly. Coming from Svelte 4’s $: blocks, which handled both derived values and side effects, it felt natural to put everything in effects.

That was a mistake. Most of the time, $derived() is what you actually want. Effects should be reserved for genuine side effects (DOM manipulation, API calls, logging), not for computing values.

Before (too many effects):

<script>
  let items = $state([]);
  let total = $state(0);

  $effect(() => {
    total = items.reduce((sum, item) => sum + item.price, 0);
  });
</script>

After (correct pattern):

<script>
  let items = $state([]);
  let total = $derived(items.reduce((sum, item) => sum + item.price, 0));
</script>

The rule I follow now: if you’re computing a value from other reactive values, use $derived(). If you’re doing something because a value changed, rather than computing a new one, use $effect().

untrack() Is More Important Than You’d Think

$effect() tracks every reactive value it reads. Sometimes you need to read a value inside an effect without creating a dependency on it. That’s what untrack() is for.

This came up repeatedly in my codebase: effects that should run when one value changes but need to read other values without re-triggering when those other values change.

<script>
  import { untrack } from 'svelte';

  let query = $state('');
  let config = $state({ limit: 10 });

  $effect(() => {
    // Re-run when query changes, but NOT when config changes
    const currentConfig = untrack(() => config);
    fetchResults(query, currentConfig);
  });
</script>

I’d estimate I use untrack() in about one out of every five effects. It’s a utility that doesn’t get much attention in tutorials but becomes essential in real applications.

Migration Was Gradual, Not All-at-Once

When I first rebuilt the site, I converted everything to runes in one pass. For this site, that was manageable. It’s relatively small.

But for larger projects, I’d recommend migrating incrementally. Svelte 5 supports both the old syntax and runes in the same project. You can convert component by component, starting with leaf components (buttons, inputs, cards) and working your way up to pages and layouts.

The migration script (npx sv migrate svelte-5) handles the mechanical conversion: let → $state, $: → $derived, export let → $props. But it can’t make architectural decisions. Some components benefit from being rethought during migration, not just mechanically converted.


Patterns I Rely On

The State Object Pattern

For components with multiple related pieces of state, I group them into a single $state() object rather than declaring separate variables:

<script>
  let form = $state({
    name: '',
    email: '',
    message: '',
    submitting: false,
    error: null
  });

  async function submit() {
    form.submitting = true;
    form.error = null;
    try {
      await sendForm(form);
    } catch (e) {
      form.error = e.message;
    } finally {
      form.submitting = false;
    }
  }
</script>

This keeps related state together and makes it easy to reset everything at once (Object.assign(form, initialState)).

Derived Chains

Complex computed values can be built by chaining $derived() calls. Each step is independently trackable and debuggable:

<script>
  let items = $state([]);
  let filter = $state('all');

  let filteredItems = $derived(
    filter === 'all' ? items : items.filter(i => i.status === filter)
  );
  let sortedItems = $derived(
    [...filteredItems].sort((a, b) => b.date - a.date)
  );
  let totalValue = $derived(
    sortedItems.reduce((sum, i) => sum + i.value, 0)
  );
</script>

Each derived value depends only on the one before it. If filter changes, all three recompute. If only an item’s value changes, only totalValue recomputes. The reactivity system handles the optimization. You just describe the data flow.

Event Handlers With Property Syntax

Svelte 5 replaced on:click with onclick. It’s a small change syntactically but a big one conceptually: event handlers are now just props, which means they compose like props:

<!-- Parent can pass handlers just like any other prop -->
<Button onclick={() => save()} variant="primary">
  Save
</Button>

<!-- The component just spreads them -->
<script>
  let { onclick, children, ...rest } = $props();
</script>
<button {onclick} {...rest}>{@render children()}</button>

This eliminated a pattern I used to dislike in Svelte 4: createEventDispatcher and forwarding events. Now it’s just props all the way down.


What I’d Tell Someone Starting Today

Start with the tutorial, but build something real quickly. The runes tutorial is good, but the patterns only click when you use them in a real component that handles real state.

Use TypeScript. Runes + TypeScript is significantly better than runes alone. The type inference for $props() and $derived() catches bugs that would otherwise slip through.

Don’t over-engineer reactivity. The most common mistake I see is creating complex webs of effects when a simple derived value would do. Start with $state and $derived. Add $effect only when you have a genuine side effect.

Read the Svelte 5 source for the components you use. The best way to learn idiomatic runes patterns is to see how the Svelte team and community use them. The patterns in production code are often simpler than what you’d invent from first principles.


A Year In

Svelte 5 runes changed how I think about reactivity. The explicit model ($state for values, $derived for computations, $effect for side effects) maps cleanly to how UIs actually work. It’s more verbose than Svelte 4’s magic, but the tradeoff is worth it: less debugging, better TypeScript support, and a mental model that scales to complex components.

If you’re still on Svelte 4, the migration is worth doing. If you’re choosing a framework today, Svelte 5 is a serious contender, not because of hype, but because the developer experience is genuinely excellent once you’ve internalized the patterns.

And if you’re curious what a Svelte 5 + runes codebase looks like in practice, you’re reading one right now. This entire site is built with it.