Nov 20, 2025
4 min read

Svelte 4 to 5 Migration: Why Tween Animations Stopped Being Reactive

Migrating to Svelte 5's new reactivity system broke our animation tweens until we discovered the hidden trap.

Upgrading to Svelte 5 seemed straightforward enough. The docs promised better reactivity, cleaner syntax, and improved performance. What they don’t tell you is that the new reactivity model can silently break your animations in ways that are really hard to debug!!

I was migrating our evolution dashboard when I hit a wall. The run status bar wouldn’t update. The pagination controls were frozen. Everything that used reactive state from our DataHandler was just… stuck.

The Problem

Here’s what was happening. In Svelte 4, you could do this:

let handler = new DataHandler(data, options)

And boom - reactivity worked. Any changes to data would flow through the handler and update the UI. Simple, elegant, exactly what you’d expect.

In Svelte 5, this pattern is broken. The new reactivity system uses $state() and $derived() runes that track dependencies at initialization time. When you initialize a DataHandler with a reactive variable, it captures the initial value of that variable, not a reference to the reactive state itself.

This means your handler gets a snapshot. A frozen moment in time. When the underlying data changes, the handler has no idea.

The Warning

Svelte 5 actually tries to warn you about this!! In the console, you’ll see:

Reactive reference was passed to function but captured in non-tracked scope

The problem is this warning is easy to miss. It shows up during development, gets buried in a sea of other logs, and doesn’t tell you which reactive reference or where the problem is. You just know something, somewhere, is wrong.

The Fix

The trick is to initialize your handler with empty data first, then populate it in an effect:

// Don't capture _runs at initialization time
const handler = new DataHandler([], { rowsPerPage: 20 })

// Populate when _runs changes
$effect(() => {
    if (_runs.length > 0) {
        handler.setRows(_runs)
        handler.sortDesc('createdAt')  // Apply default sort
    }
})

The $effect() rune creates a tracked scope. When _runs changes, the effect re-runs, and the handler gets updated properly. Reactivity restored!!

This pattern showed up everywhere in our codebase. Every DataHandler, every tween animation, every derived state that depended on external data - they all needed this treatment.

Why This Matters

Svelte 5’s reactivity is more explicit and more powerful, but it requires you to think differently about initialization. In Svelte 4, you could be lazy about when and how state was created. Svelte 5 forces you to be intentional.

The key insight is that reactivity is now scope-based, not variable-based. If you want something to be reactive, it needs to be accessed within a tracked scope ($effect, $derived, or component render). Accessing reactive state outside these scopes gives you a snapshot, not a live connection.

This makes the system more predictable once you understand it. But man, that learning curve is steep when your animations just stop working and you don’t know why.

The Lesson

When migrating to Svelte 5:

  1. Watch for the “non-tracked scope” warning
  2. Initialize with empty/default values
  3. Use $effect() to populate reactive data
  4. Never pass reactive state directly to constructors
  5. Test your animations and data handlers carefully

Turns out the migration guide wasn’t kidding when it said “reactivity works differently now.” I just wish they’d led with “your tweens will break” instead of burying it in the footnotes.

References