Reactive Data With Modern JavaScript
EventTarget and Proxies FTW
A silly little PWA has been brewing over the past couple of weekends to make a desktop-compatible version of a mobile-only native app using Web Bluetooth.
I'm incredibly biased of course, but the Project Fugu 🐡 APIs are a lot of fun. There's so much neat stuff we can build in the browser now that HID, Serial, NFC, Bluetooth and all the rest are available. It has been a relaxing pandemic distraction to make time to put them through their paces, even if developing them is technically the day job too.
Needless to say, browsers that support Web Bluetooth are ultra-modern[1]. There's no point in supporting legacy browsers that don't have this capability, which means getting to use all the shiny new stuff; no polyfills or long toolchains. Fun!
In building UI with lit-html
, a question arises about how to trigger rendering without littering code with calls to render(...)
. Lots of folks use data store libraries that provide a callback life-cycle, but they seem verbose. I'm also not keen on the FP marketing jargon that serves to remove directness of action and make simple things more complicated than they are complex.
Being reactive to data changes without a passel of callbacks or tedious conventions is, therefore, appealing. What we need to do this is:
- An object that can be an event source for listeners
- Some way to be notified of data changes
That's really it! Before modern runtimes, we needed verbose, explicit API surface. But it's 2021, and we can do more with less now thanks to Proxies
and subclassable EventTarget
.
Hewing to the "data down, events up" convention of Web Components, here's a little function my small app is using instead of a "state management" library:
// proxyFor.js
let objToProxyMap = new WeakMap();
let shouldNotProxy = (thing) => {
return (
(thing === null) ||
(typeof thing !== "object") ||
(objToProxyMap.has(thing))
);
};
export let proxyFor = (thing, // to wrap
eventTarget, // defaults to `thing`
// only watch existing properties?
currentOnly,
// data path, advanced use only
path=[]) => {
// If not an object, or already proxied, bail
if (shouldNotProxy(thing)) { return thing; }
let dataProperties = currentOnly ?
new Set(Object.keys(thing)) : null;
let p = new Proxy(thing, {
get: function(obj, prop, receiver) {
let value = Reflect.get(...arguments);
if (objToProxyMap.has(value)) {
return objToProxyMap.get(value);
}
return (
(typeof value === "function") &&
(prop in EventTarget.prototype)
) ?
value.bind(obj) : // avoid `this` confusion
proxyFor(value, // handle object trees
eventTarget,
currentOnly,
path.concat(prop));
},
set: function(obj, prop, value) {
if (!dataProperties ||
dataProperties.has(prop)) {
let evt = new CustomEvent("datachange",
{ bubbles: true, cancelable: true, }
);
// Could send through `details`, but meh
evt.oldValue = thing[prop];
evt.value = value;
evt.dataPath = path.concat(prop);
evt.property = prop;
eventTarget.dispatchEvent(evt);
}
// TODO: bookeeping to avoid potential leaks
obj[prop] = value;
return true;
}
});
eventTarget = eventTarget || p;
objToProxyMap.set(thing, p);
return p;
};
One way to use this is to mix it in with a root object that is itself an EventTarget
:
// AppObject.js
import { proxyFor } from "./proxyFor.js";
// In modern runtimes, EventTarget is subclassable
class DataObject extends EventTarget {
aNumber = 0.0;
aString = "";
anArray = [];
// ...
constructor() {
super();
// Cheeky and slow, but works
return proxyFor(this);
}
}
export class AppObject extends DataObject {
// We can handle inherited properties mixed in
counter = 0;
doStuff() {
this.counter++;
this.aNumber += 1.1;
this.aString = (this.aNumber).toFixed(1) + "";
this.anArray.length += 1; // Handled
}
}
The app creates instances of AppObject
and subscribes to datachange
events to drive UI updates once per frame:
<script type="module">
import { html, render } from "lit-html";
import { AppObject } from "./AppObject.js";
let app = new AppObject();
let mainTemplate = (obj) => {
return html`
<pre>
A Number: ${obj.aNumber}
A String: "${obj.aString}"
Array.length: ${obj.anArray.length}
Counter: ${obj.counter}
</pre>
<button @click=${() => { obj.counter++; }}>+</button>
<button @click=${() => { obj.counter--; }}>-</button>
`;
};
// Debounce to once per rAF
let updateUI = (() => {
let uiUpdateId;
return function (obj, tmpl, node, evt) {
if (!node) { return; }
if (uiUpdateId) {
cancelAnimationFrame(uiUpdateId);
uiUpdateId = null;
}
uiUpdateId = requestAnimationFrame(() => {
// Logs/renders once per frame
console.log(evt.type, Date.now());
render(tmpl(obj), node);
});
}
})();
// Wire the template to be re-rendered from data
let main = document.querySelector("main");
app.addEventListener("datachange", (evt) => {
// Not debounced, called in quick succession by
// setters in `doStuff`
updateUI(app, mainTemplate, main, evt);
});
setInterval(app.doStuff.bind(app), 1000);
</script>
<!-- ... -->
This implementation debounces rendering to once per requestAnimationFrame()
, which can be extended/modified however one likes.
There are other caveats to this approach, some of which veritably leap off the page:
- There are lurking memory leaks which a production app would want to fix
- Private class properties and methods don't work, either on
DataObj
or subclasses. Caveat emptor! - If you want something like "middleware" from other (less idiomatic) systems, you'll need to extend
proxyFor
ever so slightly - The performance of getters on objects wrapped this way is likely to not be great compared to "regular" objects, but the wrapping is lazy
- The
dataPath
and own-property options are a bit of a flourish and a real system could easily do without to save memory - There are many edge cases the proxies don't catch
In general, we win idiomatic object syntax for most operations at the expense of breaking private properties, but for a toy, it's a fun start.
Here's another way to use this, routing updates on a data object through a DOM node:
<!-- A DOM-driven reactive incrementer -->
<script type="module">
import { proxyFor } from "./proxyFor.js";
let byId = window.byId =
document.getElementById.bind(document);
let count = byId("count");
count.data = proxyFor({ value: 0 }, count);
// Other parts of the document can listen for this
count.addEventListener("datachange", (evt) => {
count.innerText = evt.value;
});
</script>
<h3>
Current count:
<span id="count">0</span>
</h3>
<button id="increment"
onclick="byId('count').data.value++">
increment
</button>
<button id="decrement"
onclick="byId('count').data.value--">
decrement
</button>
You can try a silly little test page here, DOM incrementer, or check out the Svelte Store API implemented with getProxy
underneath.
Sorry iOS users, there are no modern browsers available for your platform because Apple is against meaningful browser-choice, no matter what colour lipstick vendors are allowed to put on WebKit. ↩︎