Declarative HTML Binding in Only 20 Lines of Code! (and Signals)
Over the past few years I’ve said on many occasions that signals, a reactive primitive popularized by many JavaScript frameworks and libraries (and most easy to pick up using the implementation by the Preact folks), makes it much easier to add declarative binding to your HTML using basic vanilla DOM techniques + signal effects.
But I haven’t had a concrete example to show off just how simple yet powerful this can be…until now! 😎
In these examples, I show the ubiquitous counter demo, a todo list with a completion count, and a typewriter printing out a message—all with a binding solution in only 20 lines of code! (And that includes the import statement to load the signals library!) Here’s the entire solution:
import { signal, computed, effect } from "https://esm.sh/@preact/signals-core"
function bindSignals(container, values) {
const boundElements = [...container.querySelectorAll("*")].filter(el => [...el.attributes].some(attr => attr.name.startsWith("bind-")))
for (const el of boundElements) {
const attrs = [...el.attributes].filter(attr => attr.name.startsWith("bind-"))
for (const attr of attrs) {
if (!Object.keys(values).includes(attr.value)) break;
const name = attr.name.replace("bind-", "")
effect(() => {
if (name === "text") {
el.textContent = values[attr.value]
} else if (name === "html") {
el.innerHTML = values[attr.value]
} else {
el.setAttribute(name, values[attr.value])
}
})
}
}
}
Let me walk you through it line by line:
- I import the relevant functions from the signals library.
- I start the function block.
- I first gather up all the elements within the container featuring attributes of
bind-* - I loop through each of those elements.
- I collect one (or more) of the
bind-*attributes. - I loop through those attributes.
- If the values object (aka signals) doesn’t include the key provided in the HTML, we’ll exit the loop.
- I get the name of the attribute minus the
bind-prefix. - I create a new
effect. This is where the magic happens! This callback will re-run every time any signal or computed value changes that gets referenced within the effect. - If the attribute is
bind-text, I’ll set the text of the element with the value. - By using
textContent, we ensure all HTML is escaped (so this should be the default approach). - Or if the attribute is
bind-html, I’ll set the element’sinnerHTMLwith the value. - And here’s where we (unsafely) do that. 😅
- Otherwise…
- I’ll use the value to set the element’s attribute using whatever the label was coming after
bind-. - End block.
- End block & function.
- End block.
- End block. (Monotonous, isn’t it? 😃)
- End block.
And that’s all folks! Now you can simply pass a container element and an object containing signal values to bindSignals and suddenly your bog-standard HTML has been granted superpowers!
const basketOfSignals = {
myStr: signal("Abc"),
myInt: signal(123)
}
bindSignals(document.queryElement("..."), basketOfSignals)
Caveats: there are a few more smarts we should consider here before yeeting the code right into a repo and pushing to production!
- The attribute setting logic is too basic. What about boolean-style attributes like
hiddenwhere you wantvalue === trueto result in ahidden=""attribute andvalue === falseto result in the removal of that attribute? I think this could be done with a simple change such asvalues[attr.value].value === false ? el.removeAttribute(name) : el.setAttribute(name, values[attr.value]), but testing validation is required! - You might want to bind properties of elements and not just attributes (which will always resolve to string data), but that’s a whole ‘nuther can of worms!
- While I showcase using this technique within a custom element, that also opens up a can of worms because attribute/property reflection is usually something you want a custom element to support as part of reactivity. While it’s beyond the scope of this article, I’ve written such code in vanilla JS as well—more than once!—so perhaps I’ll document that in a future installment.
- Sometimes you’ll want to bind an array and loop through multiple elements which include bound values of objects inside the array, not just one-off values. This is definitely another degree of complexity as you have to manage adding/updating/removing multiple items (usually involving an embedded
<template>which is used to “stamp” each collection item). - If you were to set up “nested” components where the bound HTML of “inner” components shouldn’t mess up “outer” components with their own bound HTML, this solution would break. You’d need to figure out how to determine the unique boundaries of each component. If you use web components with shadow DOM, however, that might be problem solved! (Assuming such an approach doesn’t introduce other problems…)
- And finally…you will want to clean up effects when elements are removed from the DOM. If you’re in a standard website environment (an MPA), that’s not an issue because a full-page refresh will clear the JS execution context as well as the DOM. But in an SPA environment, if you remove elements which have been bound, suddenly you introduce memory leaks and possibly errors. The
effectmethod returns a disposal callback—the most obvious solution would be to gather those as you set up a custom element inconnectedCallback, and then indisconnectedCallbackyou execute the callbacks to cancel the effects.
Whew! Don’t let that all throw you off however! I think for simple projects where you want something smarter than littering your vanilla JavaScript with a bunch of textContent= statements everywhere, but less committed than pulling in an entire binding/rendering library, you can start with a solution as basic as this and ratchet up the complexity only when needed. And don’t forget, once you have a more established solution working, you could wrap it up into a little library of your own and re-use it across multiple projects.
So what do you think? Does this get you excited about using signals in your vanilla JavaScript? Are you hoping the web platform adds signals as a native feature so we don’t need to pull in a userland library? (I sure am!) Stranger things have happened!