Reciprocate š«±š¼āš«²š¾
Reactivity and attribute/property reflection for custom elements #
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.
Read this comprehensive tutorial and background story on That HTML Blog.
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 stringfalseis a boolean0is 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
countattribute is not1. Itās"1"(a string). So the simplistic JavaScript code"1" + 1doesnāt result in2, itās11. 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.countis available through the component API. - In most cases, prop mutations should reflect back to the attribute.
myCounterInstance.count = 10should result incount="10"on the HTML attribute. Generally this means serializing values to strings, but in some cases it means removing the attribute.el.booleanProp = falseshouldnāt result in abooleanprop="false"attribute but should removebooleanpropentirely. - 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:
- 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!)
- 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
signalfunction, just add theSignalsuffix. For example whilethis.countreturns the value of the signal,this.countSignalreturns 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!)