Reactive DOM nodes + reactive templates for custom elements (web components)
⭐️ a blog post
Signals are now my go-to tool to model my reactive state for my apps in the browser. However, the hard part, for me, has been figuring out how I would prefer to bring that reactivity to the DOM. I think I’ve finally landed on a solution that I like using <template>
.
For an explainer on <template>
, checkout my previous post abut building templates for custom elements. This post builds on that one.
Bring the signal to the DOM
The basic way to know when a signal is updated is to subscrbe()
to it:
const number = signal(0)
number.subscribe(newNumber => {
console.log('new number!', newNumber)
})
number.value = 1
// log: new number! 1
number.value = 2
// log: new number! 1
One can also use an effect to achieve the same thing:
const number = signal(0)
effect(() => {
// using number.value will auto-subscribe this effect to number
console.log('new number!', number.value)
})
number.value = 1
// log: new number! 1
number.value = 2
// log: new number! 1
Dispose
Both subscribe()
and effect()
return a “disposal function” which “turns them off”… basically they stop watching / tracking any signals. You have to manually dispose of these subscriptions. It is annoying to me that one cannot provide an AbortSignal
to the subscription 😠 Either way, this is what you have to do:
const number = signal(0)
const dispose = effect(() => {
console.log('yay', number.value)
})
number.value = 1
// log: yay 1
dispose()
number.value = 2
// no log
Text
The easiest way to “bind” a signal to the DOM is to update a Text
node anytime a signal’s value changes. Assuming the signal (or computed) contains a string, this would work:
function reactiveTextNode(signal) {
const node = document.createTextNode(signal.value)
effect(() => {
node.textContent = signal.value
})
return node
}
And that’s it. Now we have a Text
node that can be inserted anywhere in the DOM + it will update anytime it’s signal is updated 💪
Try this in your browser console for a full example:
// paste this on it's own line by itself or else safari will complain
preactSignals = await import('https://esm.sh/@preact/signals-core')
// then paste all this at once
const { computed, effect, signal } = preactSignals
function reactiveTextNode(signal) {
const node = document.createTextNode(signal.value)
effect(() => {
node.textContent = signal.value
})
return node
}
const name = signal('James')
const hello = computed(() => `Hello ${name.value}`)
const node = reactiveTextNode(hello)
document.body.append(node)
// and now you can update the name, which will update the DOM
name.value = 'Alice' // updates!
name.value = 'Bob' // updates again!
Inserting signals into a template
I’d like to be able to directly interpolate a signal like this:
const template = html`
<p>Hello <strong>${name}</strong></p>
`
And it would auto-create the reactive Text
node.
In my previous post I made it possible to nest template by looking through all the interpolated objects looking for a template object, and using a small custom element as a stand-in inside the template’s content.
I’ll do the same for a signal inside my Template
class:
// when pre-processing the values in the template string...
if (v instanceof Signal) {
this.#signals.set(i, v)
return `<s- data-i="${i}"></s->`
}
// then later in cloneNode()...
const signals = node.querySelectorAll('s-')
// for each custom element, replace it with a reactive text node
signals.forEach(t => {
const i = t.dataset.i
const sig = this.#signals.get(i)
t.replaceWith(reactiveTextNode(sig))
})
This would take care of it. Don’t worry if this feels out of place, it is. Checkout this new codepen for a full example with an updated Template
class and everything else I’m about to write about below.
Updating the signal in response to user input
I’d like to be able to update the name, so I can see it react. So I’m going to expand the template to include an input and a custom element which will listen for input
events and update the signal in response:
const template = html`
<update-name>
<input value=${name.value}><br>
<p>Hello <strong>${name}</strong></p>
</update-name>
`
And the custom element class:
class UpdateName extends HTMLElement {
constructor() {
super()
this.style.display = 'contents'
}
handleEvent(e) {
name.value = e.target.value
}
connectedCallback() { this.addEventListener('input', this) }
disconnectedCallback() { this.removeEventListener('input', this) }
}
customElements.define('update-name', UpdateName)
And this now is a complete app. I’ll embed the codepen right here so you can try it out and explore the full source altogether:
See the Pen Reactive DOM nodes + reactive templates for custom elements (web components) by Nathan Herald (@myobie) on CodePen.
Alternate ways to build reactive templates
mb21 replied to my previous post with a link to github.com/mb21/mastro/tree/main/src/reactive#reactive-mastro which is a very cool way to handle this.
What I like about it is it shows how you can very quickly hydrate server-side generated markup using their custom element subclass.
I, personally, am not a fan of putting things like bindings or handlers in the html directly. In my journey I’ve arrived at the above reactive text node primitive from which I can build up fairly complex web apps. And DOM elements already bubble up their events, so if I want to handle a click I’ll create a custom element like I showed above to listen for and handle it. I prefer that style.
And this is why the web is great: the tools we have in the browser are incredibly flexible, there isn’t a right way to use them.
If it works, if it meets your customers / readers / viewers needs, if it’s as standard and accessible as it can be, then you did it correctly 🫡
Open questions
While this is great to see how quickly one can put together some reactive templates, there are still a lot of questions to answer:
- What about a reactive list? (oh don’t get me started 😵💫)
- What about template slots? (sure)
- What about server-side rendering? (a very big topic indeed)
- Isn’t this making a template for every single DOM node? (it’s worse than you think)
- Shadow DOM? (they who shall not be named)
- What about effect disposal? (yeah, it’s not easy)
And I’m sure many more. What questions do you have about custom elements that haven’t been answered yet?
I am on a journey to answer these questions, and more, for myself. I think we can build highly-sophisticated web apps with only custom elements today, and I’m going to learn how.
Find me on mastodon and let me know what you think.