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:
- Watch for the “non-tracked scope” warning
- Initialize with empty/default values
- Use
$effect()to populate reactive data - Never pass reactive state directly to constructors
- 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.