Svelte in Depth The $effect.root rune
The $effect
rune is straightforward: when an effect function runs, Svelte
tracks the pieces of state it accesses and re-runs the function whenever that
state changes. However, other effect-related runes are more complex. In the
previous post, we discussed the $effect.tracking
rune; this post will focus on
the $effect.root
rune.
I closely followed the development of Svelte 5, reading through GitHub issues
and Discord discussions. The $effect.root
rune has been a source of confusion
for many, myself included, and the documentation is quite minimal:
The
$effect.root
rune is an advanced feature that creates a non-tracked scope that doesn’t auto-cleanup. This is useful for nested effects that you want to manually control. This rune also allows for the creation of effects outside of the component initialisation phase.
This post aims to clarify when to use it. A common misconception is that this rune is more widely needed than it is, so before explaining when to use it, let’s look at when not to.
Common Misconception
A major driving force behind many design decisions in Svelte 5 has been to allow
reactive code outside of components. In Svelte 4, you had to refactor your code
to use stores to extract and reuse
functionality. With Svelte 5, you can move logic from your .svelte
file into a
.svelte.js
file and it will work the same. However, understanding how this
reactive code operates outside of components can be less intuitive.
Let’s look at a simple example:
// $lib/settings.svelte.js
export class Settings {
notifications = $state(false)
constructor() {
$effect(() => console.log(this.notifications))
}
}
This is a basic class representing user settings. In the constructor, we use an
$effect
to log the value, which should run each time the notification setting
changes.
One frequently asked question is whether $effect.root
is needed if this class
is in a separate file. Suppose you want to use this class in your Svelte
component as follows:
<script>
import { Settings } from '$lib/settings.svelte.js'
const settings = new Settings()
</script>
<button
onclick={() => {
settings.notifications = !settings.notifications
}}
>
Toggle notifications
</button>
People often wonder if this setup will work as expected. Will clicking the
button log the new values to the console, or is $effect.root
necessary?
To answer this, it’s essential to understand how effects are managed in Svelte. Each effect runs within a tracking scope that tracks which pieces of state are accessed within the effect, re-running it when the state changes, and cleaning it up when the scope is destroyed. When a component is created, a tracking scope is also created for the initialization code (code that runs synchronously in the script tag) and the template (the component’s HTML).
This means that it doesn’t matter if your $effect
is defined in your Svelte
component or a separate file; as long as the effect runs in a tracking scope
(e.g., during component initialization), it will work as expected.
When it’s Needed
Since components are created within tracking scopes, your effects will work as
expected without additional steps. So when is $effect.root
necessary?
As you may have guessed, $effect.root
is needed when you want to create a
tracking scope outside a component lifecycle.
Let’s revisit our Settings
example. Allowing multiple components to create
their own instances of the Settings
class doesn’t make sense, as it could lead
to conflicting states, with each instance running its own separate effect.
Instead, we want a global settings
singleton that can be accessed from
anywhere. The revised code would look like this:
// $lib/settings.svelte.js
// Not exporting the class anymore since it shouldn’t be instantiated
// outside of this file.
class Settings {
notifications = $state(false)
constructor() {
$effect(() => console.log(this.notifications))
}
}
export const settings = new Settings()
<script>
import { settings } from './settings.svelte.js'
</script>
<button
onclick={() => {
settings.notifications = !settings.notifications
}}
>
Toggle notifications
</button>
If you try to run this code, you’ll see this error:
effect_orphan `$effect` can only be used inside an effect (e.g. during component initialisation)
.
And that’s correct! You’re trying to create an effect outside of a tracking
scope.
To resolve this, you can create a scope with $effect.root
inside your class:
// $lib/settings.svelte.js
class Settings {
notifications = $state(false)
constructor() {
$effect.root(() => {
$effect(() => console.log(this.notifications))
})
}
}
export const settings = new Settings()
Now everything works as expected: The effect in the Settings
constructor is
now managed by its own scope.
$effect.root
The Downside of Using You might wonder why you shouldn’t always use $effect.root
, since it enables
effects anywhere in your code. The reason is that a tracking scope also requires
cleanup (which will also cleanup all the effects within it). In the Settings
example, this isn’t an issue since we’re creating a singleton that lives for the
entire application’s lifetime. However, if your code could be instantiated
multiple times, you need to ensure that the scope is cleaned up when it’s no
longer needed.
In the case of a component, Svelte automatically cleans up the scope when the
component is unmounted. When you create a scope with $effect.root
, you receive
a cleanup
function that you need to call when the scope is no longer required:
const cleanup = $effect.root(() => {
// Any effects you might run here
})
If we wanted to provide an option to destroy our settings instance, we could handle it like this:
export class Settings {
notifications = $state(false)
#cleanup
constructor() {
this.#cleanup = $effect.root(() => {
$effect(() => console.log(this.notifications))
})
}
destroy() {
this.#cleanup()
}
}
When you’re finished with your settings
object, you can call
settings.destroy()
to clean up the scope and all effects inside it.
Avoiding Overuse
Try not to overuse $effect.root
. I used it in this example to demonstrate how
it works and when it’s applicable. However, for a global singleton, it’s usually
better to create the settings object in your root layout and pass it down
through context:
<!-- /routes/+layout.svelte -->
<script>
// Importing the initial Settings implementation
// without $effect.root here.
import { Settings } from '$lib/settings.svelte.js'
import { setContext } from 'svelte'
// Using the tracking scope of the root layout instead of
// creating our own with $effect.root
const settings = new Settings()
setContext('settings', settings)
</script>
This avoids all the complications surrounding $effect.root
and has the added
benefit that it makes your code easier to test.
In most cases, you’ll never need this rune for web applications. If you think you do, consider structuring your code so it’s tied to a component’s (or layout’s) lifecycle.
If you do need it, ensure either:
- the scope is created once and lives for the application’s lifetime, or
- you properly clean up the scope when it’s no longer needed.
I hope this post has clarified the $effect.root
rune. If there’s a Svelte
topic you’d like me to cover, tag me on
Bluesky!
Need a Break?
I built Pausly to help people like us step away from the screen for just a few minutes and move in ways that refresh both body and mind. Whether you’re coding, designing, or writing, a quick break can make all the difference.
Give it a try