Svelte in Depth The $effect.root rune

Nov 12, 2024

This is the 2nd post in a series on Svelte, where we delve into the framework’s more advanced and intricate features, helping developers master the framework beyond the basics.

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.

The Downside of Using $effect.root

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
Pausly movement