Svelte in Depth The $effect.tracking rune

Nov 8, 2024

This is the 1st 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.

Svelte 5 is finally here! While many things have become simpler, some new features may be a bit trickier to understand. In this series, we’ll dive into the more advanced aspects in detail. This first post focuses on the $effect.tracking rune, exploring what it does and when you might want to use it.

Definition

The Svelte team has already enhanced the documentation quite a bit, so there’s not much to add. Here’s the official description:

The $effect.tracking rune is an advanced feature that tells you whether or not the code is running inside a tracking context, such as an effect or inside your template.

This allows you to (for example) add things like subscriptions without causing memory leaks, by putting them in child effects.

They then provide an example of how a rune with subscriptions can be implemented. It’s concise, but understanding why and when you might need it may not be immediately obvious.

Why is it Needed?

To understand why this rune was added, it’s helpful to know which functionality it replaces.

In Svelte 4, there were two reactivity models:

  • Simply declaring variables in a Svelte component, letting the compiler handle reactivity.
  • Stores that could be passed around (these still exist in Svelte 5).

Readable stores have a straightforward contract: you can subscribe to them to receive updates. To subscribe to a store, you would write:

const unsubscribe = myStore.subscribe((value) => console.log(value))
// And when you’re done:
unsubscribe()

Thanks to this subscriber pattern, it’s easy to run code both when the store is subscribed to and when all subscribers unsubscribe.

With the built-in readable and writable store functions, it’s as simple as this:

import { readable } from 'svelte/store'

const initialValue = 'hello'

const myStore = readable(initialValue, () => {
  console.log('The subscriber count went from 0 to 1') 
  return () => {
    console.log('All subscribers have unsubscribed') 
  }
})

Even without the readable helper function from Svelte it would be quite easy to implement this functionality.

Naive Implementation

Let’s say we wanted to implement similar subscriber count functionality in Svelte 5, with runes instead of stores. To do this, we can use the $effect() rune which creates a tracking context managed by the component’s lifecycle.

Here’s a naive (but incorrect) approach to implementing our own simplified readable function with signals:

// $lib/utils.svelte.js

// ❌ Don't do this! This is an example of how not to implement this.
export const readable = (initial_value, start) => {
  let value = $state(initial_value)

  let subscribers = 0
  let stop = null

  return {
    // We return an object with a getter so we don't lose reactivity
    get value() {
      $effect(() => {
        // ✨ Here is the trick ✨
        // Every time the value is accessed, we create an effect in
        // which we increase the subscriber count. An $effect() will
        // run automatically when the scope is created (i.e.: the
        // component is mounted)...

        if (subscribers === 0) {
          // If there are no subscribers yet, invoke the start function
          // and store the returned function as the `stop` function.
          stop = start()
        }

        // Increase our subscriber count.
        subscribers++

        // ...and will automatically be cleaned up when the scope is
        // destroyed (i.e.: the component is unmounted). Here we can
        // decrease the subscriber count.
        return () => {
          subscribers--

          if (subscribers === 0) {
            // If it was the last subscriber, invoke the stop function
            stop?.()
            stop = null
          }
        }
      })

      // Return our reactive value.
      return value
    }
  }
}

This function is nearly identical to the built-in svelte/store readable implementation and would be used like this:

<script>
  import { readable } from '$lib/utils.svelte.js'

  const shoppingCartItems = readable(0, () => {
    console.log('We have a subscriber!')
    return () => {
      console.log('We have no subscribers anymore')
    }
  })
</script>

Items in cart: {shoppingCartItems.value}

If you try this, you’ll see that it works! When this component mounts, the start function will be invoked, and the value will be reactive. So, why do we need $effect.tracking?

The Problem

With Svelte 5’s new reactivity via signals, we can now use reactivity outside of components. Svelte proxies the underlying value and handles the logic to update our code when necessary, so we no longer need to subscribe to a store and can simply access the value directly.

However, here’s the issue: when accessing the underlying value outside of a tracking context (i.e., outside an $effect), how does the readable implementation know that accessing the property shouldn’t increase the subscriber count?

Here’s an example:

<script>
  // Our readable implementation again
  import { readable } from '$lib/utils.svelte.js'

  const shoppingCartItems = readable(0, () => {
    console.log('Start')
    return () => console.log('Stop')
  })

  // Accessing the reactive value
  console.log(shoppingCartItems.value) 
</script>

The reactive value is read when the component is initialized and our naive readable implementation will increase the subscriber count to 1. But the value is accessed outside of a tracking context. Simply accessing a value in the initializer does not track the value — the code will not rerun if the value changes. If we wanted to track it, we would need to put the access in an $effect(). This means that our readable implementation thinks that there is a subscriber although there is none.

Imagine that we use this readable in our app, and start polling for updated values as soon as there is a subscriber. With this implementation, our code will always think there is a subscriber and start polling in the background, although there actually are none — defeating the purpose of tracking subscribers in the first place.

Solution

To solve this issue, we can use the $effect.tracking rune. This rune tells us whether we’re currently inside a tracking context, which is exactly what we need. We only want to increase our subscriber count if the value has been accessed in a tracking context (i.e.: an $effect()) because only then will the code accessing the value rerun when the value changes.

So, we just need to add this check in our value getter:

// $lib/utils.svelte.js
export const readable = (initial_value, start) => {
  let value = $state(initial_value)

  let subscribers = 0
  let stop = null

  return {
    // We return an object with a getter so we don't lose reactivity
    get value() {
      if ($effect.tracking()) {
        $effect(() => {
          // Unchanged
        })
      }
      return value
    }
  }
}

The full implementation can be found in the $effect.tracking docs.

Additional Uses

As the name suggests, the primary purpose of the $effect.tracking() rune is to check whether your code is running in a tracking context. However, the rune also serves an additional purpose.

Effects are only permitted to run within a scope. If you attempt to run an effect outside a component, you will encounter the following error: effect_orphan $effect can only be used inside an effect (e.g. during component initialisation).

In our readable implementation, we want code outside a scope to still be able to access the value. By wrapping our use of $effect() inside an $effect.tracking() check, we effectively "feed two birds with one scone":

  1. We ensure that the subscriber count doesn't increase if accessed outside of a tracking context, and
  2. We avoid encountering an error if the value is accessed outside of a tracking scope.

I hope this was helpful and answered any questions you might have had about the $effect.tracking rune. Thanks for reading.

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