Turns out migrating to Svelte 5 isn’t just about replacing let with $state and calling it a day. The new runes system fundamentally changes how you think about reactivity, especially when you’re working with third-party libraries that need careful initialization timing.
I ran into this while building a neural network visualization system that uses physics engines like Box2D. The problem? Libraries like these don’t play nice with Svelte’s reactivity by default. They need to be initialized at exactly the right moment, with exactly the right data, and they definitely don’t appreciate being re-initialized every time a prop changes.
The Initialization Problem
Here’s the thing about component initialization in Svelte 5: your props might not be ready when onMount fires. This is especially true when you’re dynamically importing components or loading data from parent components. You can’t just do this:
onMount(async () => {
VehicleComp = (await import(`../../vehicles/${run.options.type}/VehicleTrial.svelte`)).default
Vehicle = await import(`../../vehicles/${run.options.type}/vehicle.js`)
vehicle = await Vehicle.Vehicle(vehiclebp, { ...run.getHurdle().opts })
})
Because vehiclebp might still be undefined!! The component mounted, but the data isn’t there yet. Classic async timing issue.
Enter $effect
The $effect rune is Svelte 5’s answer to “run this code whenever these dependencies change, but in a smart way.” It’s not just a renamed $: reactive statement. It’s more powerful and more dangerous if you don’t understand what it’s doing.
Here’s how I used it to handle vehicle initialization:
let vehicle = $state()
let Vehicle = $state()
$effect(() => {
if (Vehicle && vehiclebp && !vehicle) {
makeVehicle(vehiclebp)
}
})
async function makeVehicle(vehiclebp) {
if (!vehiclebp || !Vehicle) return
try {
vehicle = await Vehicle.Vehicle(vehiclebp, { ...run.getHurdle().opts })
} catch (error) {
console.error('[VehicleTrialWrapper] Failed to create vehicle:', error)
}
}
The key insight was the guard condition: if (Vehicle && vehiclebp && !vehicle). This ensures we only initialize once, when all dependencies are ready, but never re-initialize unnecessarily.
Caching State Across Component Instances
But wait, there’s more!! I also needed to cache test data so users don’t lose their work when switching between vehicle tabs. This is where $effect really shines:
// Cache test data whenever it changes (only if context exists)
$effect(() => {
if (ctx && savedTest.length > 0 && currentTrial) {
ctx.setVehicleTest(vehicleIndex, {
savedTest,
testScore,
currentTrial,
trialIndex
})
}
})
This effect watches multiple pieces of state (savedTest, currentTrial, etc.) and automatically syncs them to a context-based cache. No manual synchronization code. No worrying about when to call the cache update. It just happens reactively.
The CTRNN Visualization Effect
The neural network visualization had an even trickier requirement. I needed to recalculate node positions whenever the container resized OR when the network structure changed. Here’s the solution:
$effect(() => {
if (height) {
untrack(() => {
sensors = Object.entries(PToN['Sensor'])
.sort((a, b) => a[1] - b[1])
.map((d) => d[0])
neurons = Object.keys(PToN['Neuron']).sort((a, b) => a.localeCompare(b))
nScale = scaleBand()
.range([height * NTL[1], height])
.domain(range(neurons.length / 2))
.padding(0.1)
// ... calculate all node positions
lPositions = links.map((l) => {
let fromType = NToP[l.from].split('__')[0]
return {
to: nPositions[NToP[l.to]],
from: positions[fromType][NToP[l.from]],
weight: l.w,
fromType,
nid: l.from
}
})
})
}
})
Notice the untrack() call? That’s crucial. Without it, Svelte would track every single state assignment inside the effect, potentially creating an infinite reactive loop. untrack() says “yes, I’m reading and writing state here, but don’t make this effect depend on those writes.”
The State Update Dance
The most subtle use case was syncing trial data with the current time slider position:
$effect(() => {
if (trialData) {
// Bounds check - reset time if out of range (using untrack to avoid infinite loop)
if (time > trialData.length - 1) {
untrack(() => {
time = 0
})
}
untrack(() => {
({ state: _state, outputs, action } = trialData[time])
_state = { ..._state, timestep }
})
}
})
This effect reads time and trialData (tracked), but updates time, _state, outputs, and action (untracked). The trick is knowing which dependencies should trigger the effect and which should just be side effects.
When NOT to Use $effect
Don’t reach for $effect every time you need something to happen. If you can derive state with $derived, do that instead. If you can handle it in an event handler, do that. $effect is for true side effects: initializing libraries, syncing with external systems, or complex coordination between multiple reactive values.
I saw this in my own code where I had commented-out effects:
// $effect(() => {
// console.log('Time range: ', timeRange)
// console.log('Time domain: ', timeDomain)
// console.log('Time: ', time)
// })
This was logging on every slider movement. Totally unnecessary. Effects that just log or don’t actually perform side effects are code smell.
The Bottom Line
The $effect rune is powerful when you need to:
- Initialize third-party libraries at exactly the right moment
- Sync reactive state with external systems
- Coordinate complex interactions between multiple reactive values
- Cache or persist state based on changes
But remember: with great power comes great responsibility. Use untrack() liberally to prevent reactive loops. Guard your effects with conditions to prevent unnecessary re-runs. And always ask: could this be a $derived instead?
Svelte 5 isn’t just “Svelte with a new syntax.” It’s a fundamentally different mental model for reactivity. Once you get it, though, the code practically writes itself.