Open for work
← Blog

Building a better Svelte $effect rune

Reactivity-related bugs decreased by 90% by wrapping Svelte's native side-effect rune.

[ svelte ][ lint ][ quality ]

All teams have forever bugs. You fix some, you get some new ones, you fix more, then you get some old ones again, repeat.

Throughout the years I have found this comes from devs’ lack of time and/or interest to understand the basics of the library, framework, or code pattern they’re trying to apply.

I was first introduced to Svelte at doinstruct, right when the new Svelte runes were introduced. Now, whenever I learn something new, I always try to find out exactly how far I can push its limits. So, naturally, I played around with the most critical aspect of frontend reactivity; side-effects.

Having come from Vue.js, I always found Svelte’s new $effect rune appalling. Turns out, it was literally the cause of 90% of our incoming frontend bugs. It was not just in my head.

The bug class

$effect tracks every reactive value it reads. An effect that loads rows for a client also re-runs when any other state read inside the effect changes.

This introduces loads of fun stuff:

  • Infinite loops. Requests fire as often as state updates. Have fun figuring out how to time two effects that need to interact with the same observed state.
  • Reactivity cascades spread across countless components. No way to debug them other than reading every single line very carefully.
  • Uncertainty. “When will this effect fire? After this async function that updates its state completes?”, “How did this work again?”, and my favorite: “I swear this worked last time…”

You can use untrack() from Svelte as escape hatch to explicitly mark blocks with “please for the love of god don’t run the effect for this”, but, well, it sucks to use that everytime.

Explicit dependencies

Svelte fanboys will refuse to admit the $effect rune is simply badly designed. Something doesn’t have to be good only because a popular public figure in the dev space created it.

We all know that it is meant as an escape hatch, and using $derived is almost always better, especially since it allows using await now (see Svelte docs).

Problems with this line of reasoning:

  1. That doesn’t stop devs from using it.
  2. There are valid use cases for complex side-effects, as everyone knows.

In real projects, you don’t work only on the parts of the code you know best. When rotating through feature teams, picking up bugs, or refactoring, many devs were struggling to understand these complex effects other devs wrote.

I did a survey with small code snippets and asked them what they think should happen. When almost 80% got the admittedly slightly more difficult examples wrong, I was shocked. Clearly this wasn’t working for us.

Inspiration

I love Vue.js. Vue has the helper watch() (and watchEffect()), which solved all these problems in 2020 (even before React).

Component.vue
<script setup lang="ts">import { ref, watch } from 'vue'const question = ref('')watch(() => question,async (newQuestion, oldQuestion) => {// do some stuff}}) </script>
Vue.js watchers

You can react to changes of a state variable and even observe both the new and the old value before the update. Take notes, Rich Harris. But I digress.

The solution

Inspired by Vue’s approach, I realized our problem was the implicit nature of Svelte’s side-effects.

When you have to explicitly define tracked dependencies you don’t have to assume. You just read.

My own helper runEffect(dependencies, fn) takes the dependencies as a list and runs the body untracked. Only a change in dependencies re-runs the effect. It simply wraps Svelte’s own $effect(), and puts the entire callback inside untrack().

run-effect.svelte.ts
import { untrack } from "svelte";/* $effect with explicit dependencies: only a change in dependencies re-runs fn. */export function runEffect(dependencies: unknown[], fn: () => void) { $effect(() => {dependencies.forEach((dependency) => dependency);untrack(fn);});}
The concept of the helper

Migration & Usage

Migration

  1. Add the helper (full code at the end).
  2. Replace $effect with runEffect.
  3. Bonus: add a custom oxlint/bun lint rule that forbids using $effect.
Rows.svelte
<script lang="ts"> import { runEffect } from "$lib/run-effect"; let { client } = $props(); let rows = $state<Row[]>([]); $effect(() => { load(client.id).then((r) => (rows = r)) }) runEffect( () => [client], () => load(client.id).then((r) => (rows = r)) );</script>
1 component after the codemod

That’s it! You just fixed the biggest design flaw of Svelte runes. You’re very welcome.

Usage

You can track as many variables as you want, rename them, and even use await (good bye IIFEs!):

Members.svelte
<script lang="ts">import { runEffect } from "$lib/run-effect";let { client } = $props();let rows = $state<Row[]>([]);runEffect([client.settings.root.members],async (members) => {rows = await loadMembers(members)}}); </script>
Usage example: complex named state + async side-effect

Results

We no longer have confusions about how side-effects work and the numbers don’t lie. Since then, all of our apps are simply more stable and our customers more happy, which is what this is all about.

Reactivity bugs count −90 %
per 100 fix commits00.511.522.5custom wrapper1.990.19native Svelte $effect reactivity fixes per 100 fixes: 1.99custom wrapper reactivity fixes per 100 fixes: 0.19per 100 fix commits012nativeSvelte$effectcustomwrapper1.990.19native Svelte $effect reactivity fixes per 100 fixes: 1.99custom wrapper reactivity fixes per 100 fixes: 0.19per 100 fix commits012native Svelte$effectcustomwrapper1.990.19native Svelte $effect reactivity fixes per 100 fixes: 1.99custom wrapper reactivity fixes per 100 fixes: 0.19per 100 fix commits00.511.522.5native Svelte$effectcustom wrapper1.990.19native Svelte $effect reactivity fixes per 100 fixes: 1.99custom wrapper reactivity fixes per 100 fixes: 0.19per 100 fix commits00.511.522.5native Svelte $effectcustom wrapper1.990.19native Svelte $effect reactivity fixes per 100 fixes: 1.99custom wrapper reactivity fixes per 100 fixes: 0.19

Sometimes it makes sense to go against a framework’s alleged “best practice”. There’s nothing stopping you from doing what’s right for your team other than complacency.


Full Code

Of course, the real thing is a bit more complex. Here’s the full code:

run-effect.svelte.ts
import { untrack } from "svelte";type EffectDependencies<T extends readonly unknown[]> = () => [...T];type EffectCallback<T extends readonly unknown[]> =| (() => void)| (() => Promise<void>)| ((...values: T) => void)| ((...values: T) => Promise<void>);/**Tracks all dependencies deeply, and runs the callback when anything about a dependency changes.The timing of the execution is _after_ a component is mounted to the DOM or updated.Examples:runEffect(() => [count],() => updateCount(count))// The callback optionally accepts the current values of the dependenciesrunEffect(() => [currentLesson?.translations[0].title, $UserStore.language],(title, userLanguage) => {if (!title) return updateLesson(title, userLanguage) })*/export function runEffect<T extends readonly unknown[]>(dependencies: EffectDependencies<T>,callback: EffectCallback<T>) {$effect(() => {const teardown = initiate(dependencies, callback);if (teardown && typeof teardown === "function") {return teardown;}});}/**Tracks all dependencies deeply, and runs the callback when anything about a dependency changes.The timing of the execution is _before_ a component is mounted to the DOM or updated.*/runEffect.pre = <T extends readonly unknown[]>(dependencies: EffectDependencies<T>,callback: EffectCallback<T>) => {$effect.pre(() => {const teardown = initiate(dependencies, callback);if (teardown && typeof teardown === "function") {return teardown;}});};/**Tracks all dependencies deeply, and runs the callback when anything about a dependency changes.Allows nested effects that shouldn't get cleaned up automatically.*/runEffect.root = <T extends readonly unknown[]>(callback: EffectCallback<T>): (() => void) => {return $effect.root(() => {void callback();});};function initiate<T extends readonly unknown[]>(dependencies: EffectDependencies<T>,callback: EffectCallback<T>) {const values = dependencies().map((dependency) =>$state.snapshot(dependency && typeof dependency === "object" && !Array.isArray(dependency)? Object.fromEntries(Object.entries(dependency).filter(([_, v]) => typeof v !== "function")): dependency));return untrack(() => {return callback(...(values as [...T]));});}
The full helper