Infrequently Noted

Alex Russell on browsers, standards, and the process of progress.

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, trigger rendering without littering code with calls to render(...) can be a challenge. Lots of folks use data store libraries that provide a callback life-cycle, but they seem verbose and I only want things to be as complicated as they are complex.

Being reactive to data changes without a passel of callbacks only needs:

That's 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
*
* Utilities for wrapping an object or graph in a Proxy
* that dispatches `ondatachange` events to an EventTarget.
*
*/


//
// Bookeeping
//
let objToProxyMap = new WeakMap();

const doNotProxy = Symbol.for("doNotProxy");
const isProxied = Symbol.for("isProxied");

let inDoNotProxy = (obj, prop) => {
if (prop === doNotProxy) { return true; }
if (obj) {
if (prop && obj[doNotProxy]) {
if (obj[doNotProxy].indexOf(prop) !== -1) {
return true;
}
}
}
return false;
};

let shouldNotProxy = (thing) => {
return (
(thing === null) ||
(typeof thing !== "object") ||
(objToProxyMap.has(thing))
);
};

//
// proxyFor() supports two modes, "currentOnly" or "shallow"
// proxying (which is the default) avoids creating new wrappers
// for objects extracted from properties on the passed object.
// This is faster but requires that developers understand the
// limitations.
//
// The other mode, deep proxies, create new wrappers around
// each object returned from any property. This mode recursively
// wraps sub-objects in proxies that notify the passed
// EventTarget.
//
// Note that in the case of an object that is itself an
// EventTarget, one can pass it as both the first and second
// argument to notify on it directly. This also provides
// flexibility so that a notifying proxy can be wired up
// to send change events to a *different* object.
//
export let proxyFor = (thing, eventTarget=null,
currentOnly=true, path=[]
) => {

// Bail if not an object, or if already proxied
if (shouldNotProxy(thing)) {
return thing;
}
if (!eventTarget) {
console.error(
"Missing eventTarget. Could not proxy for", thing);
return thing;
}

let dataProperties = currentOnly ?
new Set(Object.keys(thing)) : null;

let inDataProperties = (prop) => {
return (!currentOnly || dataProperties.has(prop));
};

let p = new Proxy(thing, {

get: function(obj, prop, receiverProxy) {
// Avoid any potential re-wrapping
if (prop && prop === isProxied) { return true; }

let value = Reflect.get(...arguments);
let valueType = (typeof value);

if (valueType === "undefined") { return; }

if (valueType === "object" &&
value !== null &&
value[isProxied]) {
return value;
}

if (objToProxyMap.has(value)) {
return objToProxyMap.get(value);
}

// Avoid `this` confusion for functions
if ((valueType === "function") &&
(prop in EventTarget.prototype)) {
return value.bind(obj);
}

// Do not create wrappers for sub-objects
// in these cases
if (inDoNotProxy(obj, prop) ||
inDataProperties(prop)) {
return value;
}

// Handle object trees by returning a wrapper
return proxyFor(value, eventTarget, currentOnly,
path.concat(prop));
},

set: function(obj, prop, value) {
if (!dataProperties || dataProperties.has(prop)) {
let evt = new CustomEvent("datachange",
{ bubbles: true, cancelable: true, }
);
evt.oldValue = thing[prop];
evt.value = value;
evt.dataPath = path.concat(prop);
evt.property = prop;
eventTarget.dispatchEvent(evt);
}

obj[prop] = value;

return true;
}
});

objToProxyMap.set(thing, p);

return p;
};

// For deep objects that are themselves EventTargets
export let deepProxyFor = (thing) => {
return proxyFor(thing, thing, false);
};

One way to use this is to mix it in with a root object that is itself an EventTarget:

// AppObject.js

import { proxyFor, deepProxyFor } 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 deepProxyFor(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:

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 an existing element:

<!-- 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, wonder at the obligatory reactive incrementer demo, or the Svelte Store API implemented using proxyFor.


  1. 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. ↩︎