Svelte in Depth The createSubscriber() function

Jan 9, 2025

This is the 3rd 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.

One of the more obscure features that Svelte pushed out in their Advent of Svelte is the createSubscriber() function. Because it is quite similar to what you can achieve with $effect.tracking(), I thought I’d write a short post about it.

As always, let’s start with what the docs say:

Returns a subscribe function that, if called in an effect (including expressions in the template), calls its start callback with an update function. Whenever update is called, the effect re-runs.

The Svelte team then acknowledges that this is a bit hard to understand and provides an example with a MediaQuery.

When would you use it?

The best way to understand how to use this function is to look at the example they provide and try solving it without createSubscriber(). The example wants to create a reactive variable that reflects the current state of a media query. We can use the window.matchMedia() function to check if it matches and subscribe to changes:

const query = window.matchMedia('(width > 600px)')

// Read whether the media query currently matches
let queryMatches = query.matches

query.addEventListener('change', () => {
  // Update the value when the media query changes
  queryMatches = query.matches
})

A naive approach to turn this into a Svelte reactive $state without createSubscriber() would be like this:

// ❌ Careful, this implementation has a bug!
class Layout {
  #query = window.matchMedia('(width > 400px)')
  // Initialize with the current state
  current = $state(this.#getLayout())

  constructor() {
    // Subscribe to changes
    this.#query.addEventListener('change', () => {
      this.current = this.#getLayout()
    })
  }

  #getLayout() {
    return this.#query.matches ? 'desktop' : 'mobile'
  }
}

By putting our value into a $state variable, we added reactivity. The issue with this approach is that we are creating an event listener that is never cleaned up! You could add a destroy method to the class that removes the event listener and make sure that it’s invoked when the component using this class is destroyed, but this is very clumsy and error-prone.

What we actually want is this: we only care about updating our value and creating a subscriber if the value is being read inside an effect! In the post about the effect.tracking() rune, we learned how to do this, so let’s apply what we learned here. Please revisit the post if you’re unfamiliar with the concept.

Let’s update our code, so that it is able to track subscribers and will remove any event listeners when done:

import { on } from 'svelte/events'
import { tick } from 'svelte'

// ❌ This implementation is still not complete!
class Layout {
  #query = window.matchMedia('(width > 400px)')

  // Initialize with the current state.
  // This time, we’re making the value private
  // so we can wrap it in a getter.
  #value = $state(this.#getLayout())

  // Track the amount of subscribers.
  #subscribers = 0

  // Will be set to the function removing the event listener.
  #removeEventListener

  // We don’t setup listeners in the constructor anymore.
  // constructor() { }

  // Wrap the value in a getter, so we can setup the listeners
  // when we have subscribers.
  get current() {
    // If in a tracking context ...
    if ($effect.tracking()) {
      $effect(() => {
        // ...and there’s no subscribers yet...
        if (this.#subscribers === 0) {
          // ...update the value immediately...
          this.#value = this.#getLayout()

          // ...and setup an event listener.
          this.#removeEventListener = on(this.#query, 'change', () => {
            // Update the state value with the new media query value
            // whenever the media query changes.
            this.#value = this.#getLayout()
          })
        }

        this.#subscribers++

        return () => {
          tick().then(() => {
            this.#subscribers--
            if (this.#subscribers === 0) {
              // Cleanup if there are no more subscribers.
              this.#removeEventListener?.()
              this.#removeEventListener = undefined
            }
          })
        }
      })
    }

    return this.#value
  }

  #getLayout() {
    return this.#query.matches ? 'desktop' : 'mobile'
  }
}

But there is still an issue with this implementation that you might have noticed. Although we now create a subscriber to listen to changes to the query, we’re never updating the value when we’re outside an effect. This means that if you create an instance of this class and read its value, you cannot actually trust that the .current value reflects the actual state. We could just update this.#value every time the .current getter is invoked, but this seems a bit silly and inefficient. This is exactly why createSubscriber() exists.

The createSubscriber() function

Instead of shoehorning a reactive $state value into our class to get reactivity, this function allows you to manage reactivity a lot more directly.

And this is where their example comes into play. I’ll copy it here and annotate accordingly:

import { createSubscriber } from 'svelte/reactivity'
import { on } from 'svelte/events'

export class MediaQuery {
  #query
  #subscribe

  constructor(query) {
    this.#query = window.matchMedia(`(${query})`)

    // We’re creating a subscriber in the constructor. This returns a
    // function that we can invoke whenever we read a value for which
    // we want to manually manage reactivity.
    this.#subscribe = createSubscriber((update) => {
      // The body of this function is the "start" function. It will
      // be invoked as soon as there is a subscriber.
      // The update function that is provided can be invoked any
      // time, and will trigger any effect in which the `#subscribe`
      // has been invoked to re-run.

      // We’re using the `on` convenience function from Svelte to add
      // an event listener to our query. Whenever the `change` event
      // is fired, we want the `update` function to be invoked.
      // Note that we’re not updating any value here.
      // (This is just syntactic sugar for
      // `this.#query.addEventListener(’change’, update)`)
      const off = on(this.#query, 'change', update)

      // The returned function will be invoked when there are no
      // more subscribers. In this case, we want to remove the event
      // listener.
      return () => off()
    })
  }

  get current() {
    // Any time the current value is read, we invoke the `#subscribe`
    // function from the `createSubscriber`. This means that the
    // effect accessing the `current` property will re-run (and thus,
    // accessing this property again) any time the `update` function
    // is invoked.
    this.#subscribe()

    // Instead of having to wrap the value in a $state property, we
    // can now simply return the value directly.
    return this.#query.matches
  }
}

As you can see, this makes the intent of the code a lot clearer. After all, all we want is to return whether the query matches, and we want effects accessing the value to re-run whenever the underlying data changes.

How does it work?

To be able to use createSubscriber() it’s not necessary to understand how it works, but in case you’re interested, here’s a quick explanation - it’s really simple!

createSubscriber() is nothing special. It’s just a helper function that Svelte provides. You could build the same function yourself. The crucial question is this:

The answer is actually really simple! The function creates a hidden $state value named version. When the subscribe function is invoked this value is being read, and so the effect re-runs when the version value changes. Any time you then invoke the update function, version is incremented, and all effects in which the subscribe function was invoked will re-run.

To be able to run the start function when the first effect subscribes, and cleanup when there are no subscribers left, the createSubscriber() function uses the same technique we used before with $effect.tracking to count the subscribers.

Conclusion

Whenever you want to expose a value from an external source (for example a media query) that is not reactive but can change over time, and you want to expose it as a reactive value, createSubscriber() is a good choice. Even if media queries didn’t have a change event, you could setup an interval and periodically check the value and invoke the update function when appropriate.

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