Helper utility for adding signal-based reactivity and attribute/property reflection to custom elements https://thathtml.blog/2025/09/reciprocate-reactivity-for-html-web-components/
  • JavaScript 100%
Find a file
2025-09-08 17:29:07 +00:00
src feat: clean up attribute callback functionality so it's nicer 2025-09-01 18:43:00 +00:00
test remove Express and use Node builtins for static http test server 2025-09-06 17:24:50 +00:00
types add typing 2025-09-08 17:25:58 +00:00
.gitignore simplify test setup 2025-09-01 05:51:41 +00:00
jsconfig.json add effects runtime management 2025-09-01 17:33:44 +00:00
package-lock.json add typing 2025-09-08 17:25:58 +00:00
package.json include types in package.json 2025-09-08 17:29:07 +00:00
README.md Merge branch 'main' of ssh://codeberg.org/heartml/reciprocate 2025-09-08 17:26:05 +00:00
tsconfig.json add typing 2025-09-08 17:25:58 +00:00

Reciprocate (by the ❤️ Heartml project)

npm

A helper utility for adding signal-based reactivity and attribute/property reflection to any vanilla custom element class. This adheres to the Heartml philosophy of providing "unbundled" features you can add to your own web components-based frontend architecture.

Installation

npm i @heartml/reciprocate

Reciprocate doesn't include its own signals implementation. We support any implementation which can provide an API adhering to the TC39 proposal. For example, you can use the Signals library by the Preact team:

npm i @preact/signals-core
import { signal, effect, Signal } from "@preact/signals-core"

Signal.prototype.get = function() {
  return this.value
}
Signal.prototype.set = function(newValue) {
  this.value = newValue
}

See the test fixture here for a different installation using alien-signals.

Usage

Given an HTML template:

<simple-greeting></simple-greeting>

You can write a web component for this custom element which is able to react to attribute and/or property changes:

import { reciprocate } from "@heartml/reciprocate"

export class SimpleGreeting extends HTMLElement {
  /** Attributes you want to watch for signal updates and property reflection */
  static observedAttributes = ["whoa", "mood"]

  static {
    customElements.define("simple-greeting", this)
  }

  /** Used to store one or more rendering effects */
  #effects

  constructor() {
    super()
    this.attachShadow({ mode: "open" })

    /** This is a groovy greeting */
    this.whoa = "Whassup"

    /** How are you feeling today? */
    this.mood = "😃"

    /** Initialize the Reciprocate functionality for this component */
    this.#effects = reciprocate(this, signal, effect)
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `<slot></slot><p id='emoji'></p>`

    /** Create the effects which run whenever signal values are changed */
    this.#effects.run(
      () => (this.textContent = this.whoa),
      () => (this.shadowRoot.querySelector("#emoji").textContent = this.mood)
    )
  }

  disconnectedCallback() {
    this.#effects.stop((fn) => fn())
  }

  attributeChangedCallback(name, _, newValue) {
    this.#effects.setProp(name, newValue)
  }
}

In this example, the component will by default render Whassup in light DOM, and the 😃 emoji in sides shadow DOM template. However, you can modify this in two distinct ways:

  • <simple-greeting whoa="Howdy" emoji="🤠"></simple-greeting> — the attributes will be applied to the component instead of the default. You can also edit these attributes in real-time and the component will re-render, and any of these changes will reflect back to the property values in JavaScript.
  • document.querySelector("simple-greeting").whoa = "Yo" — you can access the properties via JavaScript and set them to new values. When you do so, the new property values will reflect back to the attributes in the DOM.

Since attributes in HTML are always string values, how does Reciprocate handle numbers, booleans, or JSON arrays/objects? By setting your property defaults in constructor(), Reciprocate will infer the type:

  • "" is a string
  • false is a boolean
  • 0 is a number
  • [] is an array
  • {} is an object

In addition, camelCase JS property names will automatically be converted to kebab-case HTML attribute names (you can also omit the hyphens if you wish), and visa-versa.

🤔 You may also be wondering, how does Reciprocate even know the existence of properties to covert to signals? It's surprisingly straightforward! When you call the reciprocate function, it calls Object.keys on the component and iterates on those properties which will be replaced by getters/setters using signals under the hood.

This does mean if you want properties which aren't reflected back to attributes or don't use signals/affect rendering, you'll want to set them after the call:

this.propThatReflects = 123

this.#effects = reciprocate(this, signal, effect)

this.propThatsJSOnly = 456

In addition, any custom getters/setters don't affect the Reciprocate setup.

Zero Boilerplate 😃

It's possible to eliminate even the minor amount of boilerplate demonstrated above for each individual component if you use your own base class:

class BaseElement extends HTMLElement {
  #effects

  constructor() {
    super()
    this.attachShadow({ mode: "open" })
  }

  initialize(fn) {
    fn()
    this.#effects = reciprocate(this, signal, effect)
  }

  render(...fx) {
    this.#effects.run(...fx)
  }

  disconnectedCallback() {
    this.#effects.stop((fn) => fn())
  }

  attributeChangedCallback(name, _, newValue) {
    this.#effects.setProp(name, newValue)
  }
}

Given the above, your component class could be streamlined:

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)
    )
  }
}

Further Rationale

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.

Here's an example of what we're dealing with when building a "simple" web component:

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

It's a typical "counter" example: click the + or - buttons, see the counter update.

In component-speak, we see here that count is a prop of the my-counter component. In this particular instance it's set to 1. Even with this very simple example, we run into an immediate conundrum:

  • The type of the count attribute is not 1. It's "1" (a string). So the simplistic JavaScript code "1" + 1 doesn't result in 2, it's 11. You need to parse the attribute's string value to a number before using it as a property value.
  • You need to build getters/setters so myCounterInstance.count is available through the component API.
  • In most cases, prop mutations should reflect back to the attribute. myCounterInstance.count = 10 should result in count="10" on the HTML attribute. Generally this means serializing values to strings, but in some cases it means removing the attribute. el.booleanProp = false shouldn't result in a booleanprop="false" attribute but should remove booleanprop entirely.
  • Handling both attributes and properties with value parsing and reflection isn't the end of it. You also need to avoid an infinite loop (aka setting a property reflects the attribute which then sets the property which then reflects the attribute which then…).
  • And to top it all off, you need to be able to observe attribute/prop changes in order to do something—the side effect of the mutation.

Given all this, you're left with two choices:

  1. You can build all of this boilerplate yourself and include that custom code in all of your web components. Because obviously trying to write this over and over by hand is a real PITA! (Believe me, I've done it! I know!)
  2. You can reach for a helpful web component library which does this all for you. 😎

Unfortunately, the second option typically doesn't mean reaching for a library which only solves these problems, but one which attempts to address a host of other problems (templates, rendering lifecycles, and various other UI component model considerations).

Personally, I tend to like libraries which do one thing and one thing only—well. All that Reciprocate does is give you reactive properties by setting up a ReciprocalProperty class and corresponding signal for each of your properties. And then, thanks to the Signals library from the folks at Preact or a similar one like it, you can write one or more effects which will re-render (mutate) your component DOM based on those values which can change over time. Boom. Done

Implementing That Counter

Here's an example of using Reciprocate to write that counter component, and we even show how you can skip the initial DOM render if you already expect the component will have been rendered with the appropriate content from the server!

<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)
  }
}

What I love about this example is you can read the code and immediately understand what is happening. What ends up happening may feel a bit magical, but there's really no magic at all.

Under the hood, there's a count signal which we've initialized with 0. this.count++ is effectively countSignal.set(countSignal.get() + 1) and this.count-- is effectively countSignal.set(countSignal.get() - 1). The side effect function we've written will take that value and update the <output> element accordingly whenever the count attribute/property changes.

Whether the count attribute is set/updated through HTML-based APIs, or the count prop is set/updated through JS-based APIs, the signal value is always updated accordingly, and that then will trigger your side-effect.

And because you're using Signals, you can take advantage of computed values as well which unlocks a whole new arena of power:

import { computed } from "@preact/signals-core"

// add to the bottom of your `constructor`:
this.times100 = computed(() => this.count * 100 )

Now every time the count prop mutates, the this.times100 signal will equal that number times one hundred. And you can access this.times100.get() directly in an effect to update UI with that value also!

Note

If you ever need to access the actual object/function itself that was returned by the signal function, just add the Signal suffix. For example while this.count returns the value of the signal, this.countSignal returns the signal itself.

You can set up multiple effects to handle different part of your component UI, which is why this approach is termed "fine-grained" reactivity. Instead of a giant render method where your component has to supply a template handling all of the data your component supports, you can react to data updates in effects and surgically alter the DOM only when and where needed.

The potential is high for an additional, slightly-more-abstract library to take advantage of this to provide markup-based, declarative bindings between DOM and reactive data.

Hmm… 🤔 (stay tuned!)


Testing

Run npm run test to run the test suite. It's built with Mocha, Puppeteer, and a Node static site.

Contributing

  1. Fork it (https://codeberg.org/heartml/reciprocate/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

License

MIT