Svelte in Depth The createSubscriber() function
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 itsstart
callback with anupdate
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.
createSubscriber()
function
The 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