September 8, 2025 Permalink
Have you ever been chipping away on a vanilla web component when you began to wonder “hey waitaminute…how do I make it so when I set this JavaScript property, the equivalent HTML attribute is updated? And if I set this attribute, the equivalent property is updated? Or when either is updated, my component will re-render?! Where are all the APIs for this stuff??”
Alas, there are none. 😥
Why the native web platform doesn’t provide primitives for custom elements to act like, well, any other native element is a question for another time…because you need not worry about this strange oversight any longer! 🤓
Introducing Reciprocate.…
September 8, 2025 Permalink
Have you ever been chipping away on a vanilla web component when you began to wonder “hey waitaminute…how do I make it so when I set this JavaScript property, the equivalent HTML attribute is updated? And if I set this attribute, the equivalent property is updated? Or when either is updated, my component will re-render?! Where are all the APIs for this stuff??”
Alas, there are none. 😥
Why the native web platform doesn’t provide primitives for custom elements to act like, well, any other native element is a question for another time…because you need not worry about this strange oversight any longer! 🤓
Introducing Reciprocate. This has been a long time coming…
(Feel free to skip down to the explainer bits, or keep reading for a little backstory!)
Approximately three years ago, I first started noodling around with the then brand-new Signals package from the Preact team. It felt so whiz-bang and cool, I couldn’t even wrap my head around it at first. Once I reverse-engineered it enough that I could understand it…jeeezzus, what a mind job. 😎
After a bit of time passed, it started to dawn on me that this could prove to be a great underlying foundation for handling “fine-grained reactivity” within web components, as a lightweight alternative to the Lit base element (which I had been using a lot at the time).
I spent 2023 on-and-off iterating on a solution and for a brief while released it as part of some other experiments I’d been working on with and alongside Lit. Yet I eventually arrived at the decision to embark on development of my own custom element-based component format which would also offer server-based rendering and various neat-o features like HTML Modules. This umbrella project I called ❤️ Heartml.
As I was working on building up that feature set more and more, I chickened out on my original goal of offering a series of “unbundled” micro-libraries which people could simply add to their own vanilla web components. I figured if anybody would care to adopt Heartml, it’d have to be an install-and-you’re-done turnkey solution.
However…
Fast forward to summer 2025, and I was feeling incredibly uncertain about my approach with Heartml. One of the stated goals of the Lit project is that as more native web APIs offer good “DX” and helpful ergonomics for web developers, Lit itself would need to ship less code. I love that philosophy! With Heartml, I started to feel like I should take that concept even a step further by making all of its userland features opt-in, rather than just another magical black box you feed your own code into.
So I started over…with my original plan. 😅 I stripped out the reactivity from a monolithic HeartElement (which thankfully wasn’t too challenging because my code was still very modular), polished it up with some new smarts to make creating signals and writing “effects” easier than ever, and tada! I’m proud to release it as the first public Heartml library: Reciprocate. 🎉
Get Right Into It
So, what does Reciprocate do? To quote from the project:
Reciprocate is a helper utility for adding signal-based reactivity and attribute/property reflection to any vanilla custom element class.
The
ReciprocalProperty
class takes advantage of fine-grained reactivity using a concept called “signals” to solve a state problem—often encountered in building components (vanilla or otherwise). Signals are values which track “subscriptions” when accessed within an side-effect callback. You write an effect, access one or more signals within that effect, and then any time any of those signal values change, that effect is re-executed.
Now if you’ve ever tried to write attribute/property reflection yourself (supporting multiple value types like numbers, booleans, and even JSON) and then include that custom code in all of your web components, you know what a PITA it is. And believe me, I’ve done it far too many times. I’m more than happy to let a tiny library handle it for me! And being able to use signals for those attribute/property values in order write rendering effects against? That’s the cherry on top! 🍒
Reciprocate tries to be so unbundled, it doesn’t even come with its own signals implementation. In the readme it shows how to use Preact Signals, but you can also use alien-signals or potentially any other library out there. And one day if TC39 adds signals support into JavaScript directly, we can immediately leverage that!
Here’s an example of what writing the ubiquitous counter demo looks like using Reciprocate. Notice that we don’t inherit from anything other than HTMLElement
. Yes, this is a real vanilla custom element! Reciprocate is simply an add-on. Even better, it uses “HTML Web Component” smarts to preserve the server-rendered content as-is and only “resume” it for future renders.
<my-counter count="1">
<button type="button" data-behavior="dec">Dec -</button>
<output>1</output>
<button type="button" data-behavior="inc">Inc +</button>
</my-counter>
export class MyCounter extends HTMLElement {
static observedAttributes = ["count"]
static {
customElements.define("my-counter", this)
}
#effects
#resumed = false
constructor() {
super()
this.count = 0
this.#effects = reciprocate(this, signal, effect)
}
connectedCallback() {
this.addEventListener("click", this)
/** Create the effects which run whenever signal values are changed */
this.#effects.run(
() => {
const countValue = this.count // set up subscription
if (this.#resumed) this.querySelector("output").textContent = countValue
}
)
this.#resumed = true
}
handleEvent(event) {
if (event.type === "click") {
const button = event.target.closest("button")
switch(button.dataset.behavior) {
case "dec":
this.count--;
break;
case "inc":
this.count++;
break;
}
}
}
disconnectedCallback() {
this.#effects.stop((fn) => fn())
}
attributeChangedCallback(name, _, newValue) {
this.#effects.setProp(name, newValue)
}
}
OK, as you can see we’re handling click events, managing state, and re-rendering on state changes. All the typical stuff you need to deal with in components. And if you’re wondering to yourself, wait, how does it know that this.count
should be turned into a signal?! That’s thanks to the power of Object.keys
. Reciprocate knows what your properties are right when it’s called and sets up the appropriate signals and getters/setters.
And if you’re also wondering to yourself, well, that does feel like a fair bit of boilerplate mixed in with component code 😕…never fear. You can write a lightweight base element (an example is in the readme) and then each component will be about as simple as can be! For example:
export class SimpleGreeting extends BaseElement {
static observedAttributes = ["whoa", "mood"]
static {
customElements.define("simple-greeting", this)
}
constructor() {
super()
this.initialize(() => {
this.whoa = "Whassup"
this.mood = "😃"
})
}
connectedCallback() {
this.shadowRoot.innerHTML = `<slot></slot><p id='emoji'></p>`
this.render(
() => (this.textContent = this.whoa),
() => (this.shadowRoot.querySelector("#emoji").textContent = this.mood)
)
}
}
There are a lot more details about how Reciprocate works in the readme, and you can even take a peek at the test suite for a riff on the “declarative signal binding” demo featured previously on That HTML Blog, which in theory would mean you could eliminate the rendering effects in JavaScript—let the HTML template provide the logic for you! 🤯
As I mentioned up top, Reciprocate is the first library in a series of libraries to come from the Heartml project. Next up, I’m hoping to feature an interesting take on server roundtrip of interactive commands based on using fetch
(via forms or otherwise). By combining web components and a modular set of commands via server requests, you could build a very sophisticated client/server web application with still mostly vanilla code.
In summary…
A lot of libraries out there—even really good ones—want you to do things their way. I love Lit, but Lit is quite opinionated and falls down in scenarios where Lit is just a suboptimal solution. Likewise, I also admire htmx quite a bit, but that too is very opinionated and throws a lot of “magic” at you that you might not even want or need.
I like tools which adhere to the Unix philosophy. I like micro-libraries which you can chain together to make higher-order logic work the way you want it to. I don’t want to replace HTMLElement
. I don’t want to replace fetch
. I simply want to provide you with some ergonomics to make vanilla web APIs feel even more powerful and expressive. That’s the goal. Feel free to submit issues and PRs with your feedback and ideas!
Some Technical Details
Reciprocate is a small library, but some of the details of building it may interest you.
First of all, everything is written in JavaScript. Not TypeScript. This is a fundamental belief I hold: we should be writing for the web with the language of the web.
However, typing information is very useful and I would argue is a form of documentation. Furthermore, the act of static typing is the act of documenting your code. As such it makes perfect sense to use JSDoc. By annotating your code with JSDoc comments, both the types as well as other information you want to share about classes and methods and functions can all live within the same syntax that is 100% JavaScript compatible.
It’s easy to build and export types, as the TypeScript docs demonstrate. Other folks who write with JSDoc-based JavaScript or TypeScript proper can equally benefit from those types published in the NPM package. This sensible approach has already been adopted by a growing number of well-known packages such as the Svelte frontend framework.
Now let’s talk about testing.
In the past, I had been using Modern Web’s web-test-runner to write real browser-based tests for JS code, but a couple of reasons gave me pause for this latest project:
- It felt a bit like a magic black box to me, and as you may be surmising by now, I hate black boxes. 😅
- I had been using web-test-runner with Playwright for cross-browser testing which is a project from Micro$oft. I am actively boycotting all Microsoft technology at this point, which left me needing to use something else. While I’m by no means a fan of Google either, Puppeteer is a project by the Chrome team directly which feels relatively “neutral” to me. And if I’m going to bother switching to Puppeteer, why not re-examine my whole testing infra? So after a ridiculous amount of yak shaving to find a possible alternative, I ended up with a blessedly straightforward setup using Mocha.
Mocha is an oldie but goodie in the JavaScript testing world, which is something I tend to love. Now out of the box Mocha doesn’t offer browser-based testing per se, which is why you need to spin up a static server in order to test via Puppeteer. Initially I used Express because that’s what was demonstrated in this Mocha example, but then I started to wonder if I even needed that dependency.
Turns out, I did not! (MDN) You can built a functional zero-dependency static web server with Node, as that linked MDN article shows, and after a few tweaks I was able to wave Express bye-bye.
The nice thing about this setup is it’s incredibly fast. I can run the tests from scratch in just a couple of seconds. And furthermore, I’m testing an entire website! If I were to spin up the server outside of the test harness and visit it in a web browser, I could see the exact same behavior on display.
The only possible downside to my setup is it’s buildless. 🤯 That’s right, I’m not using a bundler of any kind anywhere. Not for the tests, not even for publishing my package! Everything is raw ESM, and I think that’s the coolest thing ever. The static web server literally loads dependency code right out of the local node_modules
folder. It’s possible that some setup in the future will break down if I need to utilize a package that doesn’t publish to NPM well for a buildless architecture. But so far, so good!
(But…you may be wondering why I don’t publish a “dist” flavor of the package? Because there are plenty of CDNs out there which will do that for you! esm.sh, jsDelivr, and others. And of course, a local bundler like esbuild will likewise solve the same problem. It’s time to embrace our glorious ES Modules future, and bid CommonJS and shipping janky bundled/minified code farewell!)
One final note: the Reciprocate repo is hoted on Codeberg! That’s right, my boycott of Micro$oft includes GitHub as well, which at this point might as well be called Micro$oft Copilot CodeHub 3000. Personally, I refuse to play their idiotic games, and I heartily encourage you to migrate off of GitHub yourself and support a true open source ecosystem!
So there you have it. I’m very pleased with the architecture of this new package, so much so that I expect it to be the blueprint of many more to come in the ❤️ Heartml family. Enjoy! 😊