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! 😎

View the CodePen

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:

  1. I import the relevant functions from the signals library.
  2. I start the function block.
  3. I first gather up all the elements within the container featuring attributes of bind-*
  4. I loop through each of those elements.
  5. I collect one (or more) of the bind-* attributes.
  6. I loop through those attributes.
  7. If the values object (aka signals) doesn’t include the key provided in the HTML, we’ll exit the loop.
  8. I get the name of the attribute minus the bind- prefix.
  9. 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.
  10. If the attribute is bind-text, I’ll set the text of the element with the value.
  11. By using textContent, we ensure all HTML is escaped (so this should be the default approach).
  12. Or if the attribute is bind-html, I’ll set the element’s innerHTML with the value.
  13. And here’s where we (unsafely) do that. 😅
  14. Otherwise…
  15. I’ll use the value to set the element’s attribute using whatever the label was coming after bind-.
  16. End block.
  17. End block & function.
  18. End block.
  19. End block. (Monotonous, isn’t it? 😃)
  20. 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!

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!

View the CodePen

Skip to content