Skip to content

Svelte 5: The Release I Finally Migrated To

Svelte 5: The Release I Finally Migrated To

Okay, this is going to sound dumb, but I held out on Svelte 5 for almost a year after it shipped. Not because I disliked runes. I hadn’t even tried them. I held out because I’d just finished migrating a client app to Svelte 4.2 and the thought of writing one more $: reactive statement to test a new release made me want to lie down.

I finally did the migration over a long weekend in early 2025 and immediately wished I’d done it sooner. Three apps and roughly twenty months later, I’m shipping all my new Svelte work in v5 and I’ve back-ported runes to two older apps. There’s plenty I’d warn you about. The $effect debugging story still has gaps, and migrate-everything-at-once is a trap.

But the model is genuinely cleaner. Here’s what I actually use, what I’d skip, and the rough mental model that finally clicked for me.

Why I dragged my feet on the migration

The honest reason: Svelte 4 worked. The let foo = 0 reactivity felt magical. $: blocks read like English. Stores were a bit clunky, but I knew their edges. Throwing all that out for a new mental model felt like work I didn’t need.

What changed my mind was a colleague’s bug report. They couldn’t tell why a $: block was firing twice. We spent an hour staring at the dependency graph the compiler had inferred, which turned out to depend on the order of imports. That’s when I realized “magical reactivity” had a cost I’d been paying without noticing.

The Svelte team’s release post made the new model sound boring in a good way: explicit $state, explicit $derived, explicit $effect. No more reading the compiler’s mind. If anything, my main worry switched from “is this too different from Svelte 4?” to “is this too similar to React hooks?” The answer turned out to be no, but it took me a few apps to feel that.

$state and $derived: the rules that replaced stores for me

Most of what I used Svelte stores for is now just $state. Cross-component shared values that aren’t tied to a single component tree get a module-scoped $state and that’s it.

<!-- Svelte 4 -->
<script>
  import { writable, derived } from 'svelte/store'
  const count = writable(0)
  const doubled = derived(count, $c => $c * 2)
</script>

<button on:click={() => $count++}>{$count}</button>
<p>{$doubled}</p>
<!-- Svelte 5 -->
<script>
  let count = $state(0)
  let doubled = $derived(count * 2)
</script>

<button onclick={() => count++}>{count}</button>
<p>{doubled}</p>

The two rules that took me a week to internalize:

  1. $state makes the variable a reactive reference. Reassigning works and mutating works, but you can’t destructure it and expect reactivity.
  2. $derived runs on every access if dependencies changed. It’s lazy and memoized, not an effect.

The destructure trap bit me in real code. I had let { user } = $state(loadUser()) and wondered why no template updated. The reactivity sits on the variable holding the state, not the contents. I fixed it by keeping the rune at the outermost binding: let user = $state(loadUser()) and reading user.name everywhere.

For shared state across components I now write tiny .svelte.ts modules that export a $state and a few helpers. No more writable/subscribe ceremony.

$effect: the one rune I had to slow down on

$effect is where Svelte 5 looks most like React. It runs after the DOM updates, you can return a cleanup function, and you should keep them small. The temptation is to reach for $effect whenever something reactive needs to “do” something. Most of the time, you don’t need it.

<script>
  let count = $state(0)
  let history = $state([])

  // Bad: reactive write inside an effect can loop
  $effect(() => { history.push(count) })

  // Better: derive, don't accumulate
  let trail = $derived([...history, count])
</script>

The footgun I keep seeing in code reviews is $effect that writes to another $state. Sometimes you need it, but most of the time the cleaner option is a $derived value that lazily reads what you need. I keep a rule on each project: every $effect requires a comment explaining why a derived couldn’t do it. That alone deleted half the effects from my last codebase.

The other thing I learned the hard way: don’t put async work directly inside an effect without a cleanup. I had an effect that kicked off a fetch on every input change. Two characters of fast typing later, three requests were racing for the same DOM slot, and the third-newest response won. The fix was a cancellation token returned from the cleanup function, which I should’ve reached for from the start. Treat $effect like a useEffect for cleanup discipline, even though the dependency tracking is automatic.

If you’re coming from React, the Svelte docs on runes are worth a slow read, especially the section on effect ordering. The runtime semantics are simpler than React’s, but the docs assume you’ve internalized them.

Snippets vs slots: where the new pattern actually paid off

Snippets replaced slots in Svelte 5 and they’re the change I was most skeptical about. After a few projects, I’d take them over slots without thinking. The reason: they’re plain values you can pass around and call multiple times. Slots had a weird halfway-data-halfway-template feel.

<!-- Svelte 4 with named slots -->
<DataTable>
  <thead slot="header">
    <tr><th>Name</th><th>Email</th></tr>
  </thead>
  <tbody>
    {#each rows as row}
      <tr><td>{row.name}</td><td>{row.email}</td></tr>
    {/each}
  </tbody>
</DataTable>
<!-- Svelte 5 with snippets -->
<DataTable {rows}>
  {#snippet header()}
    <tr><th>Name</th><th>Email</th></tr>
  {/snippet}
  {#snippet row(item)}
    <tr><td>{item.name}</td><td>{item.email}</td></tr>
  {/snippet}
</DataTable>

In the consumer of DataTable, the row snippet is just a callable that takes an item. Inside DataTable, I render it with {@render row(r)} and pass whatever data I want. No more weird <svelte:fragment slot="..."> workarounds for slots that needed local data.

Where snippets surprised me: render props. I used to import a render-prop component from React-land to handle list virtualization. In Svelte 5 I just pass a snippet. The mental cost of “is this a slot or a child or a component?” mostly went away. If I’m bridging frameworks more deeply, I’ve compared content frameworks here, and the snippet model holds up well against islands too.

What I’d skip in v5

Not everything in v5 lands cleanly. $inspect is fine for dev but I keep forgetting to remove it before commits. The class-syntax stores (class { count = $state(0) }) feel cute, but I haven’t found a real use case yet. Most of my “stateful classes” turn out to be small modules with a couple of $state exports.

The migration script handles the basics, but anything load-bearing like form components or reactive store derivations across files, I rewrote by hand. I trust the rewrite. I don’t fully trust the auto-migrated output.

And I still haven’t moved my SvelteKit forms to use runes. The $app/forms helpers are still the cleanest way I know to handle progressive enhancement. The same instinct shows up in my React 19 forms post: use the framework-native form helper before reaching for something cleverer.

What I’d do this week

Two suggestions if you’ve been on the fence:

  1. Spin up a fresh Svelte 5 app and rewrite one small page from a Svelte 4 project. Don’t migrate the whole thing at once. The rune patterns click faster on greenfield code than on porting work.
  2. Replace one writable-based store with a .svelte.ts module that exports a $state and two helpers. The diff is usually shorter and the call sites stop looking like incantations.

If you’re already on v5 and stuck, the place I usually find bugs is in $effect blocks that should have been $derived values. Audit those first.

I work across React, Svelte, and Laravel apps daily and write up the patterns that survive contact with real projects on my site. Svelte 5 has been the rare migration where I’d do it again, sooner, with fewer notes in my “things to fear” list.