this space intentionally left blank

December 13, 2023

Filed under: tech»web»components

What, When, Where: Event-driven apps from modern browser primitives

React's big idea was always the render function. Even at its initial presentation in 2013, the developers were very clear that the original class syntax was just a thing they added to meet contemporary expectations. They also, at the time, stressed that React could be mixed into other code. You could migrate your application over to it piecemeal, taking advantage of the speed improvements they promised in hot spots.

Over the following decade, React took over the whole application space, but conceptually it never moved past render(), and in fact almost everything else was gradually stripped away. When the deprecation of class components removed local state and lifecycle methods, they were replaced with stores like Redux, or contexts, and eventually hooks — all of which are complex and come with a laundry lists of caveats and limitations, but they "solve" the problems caused by eliminating everything that isn't a pure function. The history of the entire project has been constant, downward pressure, moving everything into the view callback. It's all one undifferentiated slab of JSX now.

Perhaps this marks me as a radical, but my thesis is that it may not be beneficial to try to reduce your solutions until they can fit in a cramped, ideologically-constrained display layer. I think it's a good thing when an application has a little flexibility depending on the problems relevant to each part, just as it's good to build a house out of different materials instead of just pouring concrete into a giant mold and calling it a day.

When critics say that web components are incomplete compared to React and its competitors, they're not wrong: if you want One Weird Trick for your entire codebase, you'll be disappointed. But if you're using web components, it may be useful to ask whether you can get many of the benefits of frameworks — live updates, cross-cutting data, loose coupling — without going down the same rabbit holes or requiring the extensive build infrastructure they depend on. It's worth thinking about what we could do if we used the platform to fill those gaps, and for me that starts with events.

Subscribable stores

A common problem: I want to share some state across components that are not located close to each other in the UI tree, and be notified when that state changes so I can re-render.

The most basic solution to this is an event emitter with getter/setter methods wrapping its value. Back in the bad old days, you'd have to roll your own, but EventTarget (the common interface for all DOM classes that dispatch events) has been widely subclassable for a few years now. Our store definition probably looks something like this:

class Store extends EventTarget {
  state = undefined;
  
  constructor(initial) {
    super();
    this.state = initial;
  }

  get value() {
    return this.state;
  }
  
  set value(state) {
    if (this.state == state) return;
    this.state = state;
    this.notify("update", state);
  }
  
  //convenience method for atomic get/set
  update(fn) {
    this.value = fn(this.state)
  }
  
  notify(type, detail) {
    this.dispatchEvent(new CustomEvent(type, { detail }));
  }
}

When we want to use this, it's largely similar to the way that a "context" works in other frameworks. You set up a store in a module, and then in places where that data is important, you import it and either subscribe, update its value, or both. Depending on your base class and your templating, you can even auto-subscribe to it in the course of rendering — remember, addEventListener() automatically de-duplicates listeners, so it's safe to call it redundantly as long as you're passing in the same reference (i.e., use a bound method or a handler object, not a fresh arrow function).

This particular store would need to be adapted if your data is deeply-nested, or if you're planning to mutate it in place, since it only notifies subscribers if the reference identity of its data changes. One option would be to build a proxy-based reactive object, similar to what Vue uses, which can be done in about a hundred lines of code for the basics. You could just import @vue/reactivity, of course, but it's educational to do it yourself.

The subscribable store can be designed with a particular shape of object or collection in mind, and offer methods for working with that shape. In my podcast client, I use a Table class that provides promised-based IndexedDB access and fires events whenever feeds are added, removed, or updated in the database.

My other favorite use case for subscriptions is anything based on external stimuli, such as network polling or push notifications, especially if that external source has rich, non-uniform behavior (say, a socket that syncs state with the server, but also lets you know when your app doesn't have network connectivity so that the UI can disable some features).

This design for reactivity is no longer fashionable, but the pace of JavaScript's pop culture makes it easy to forget that it was only 2019 when Svelte v3 (to pick an example) moved from an explicitly event-driven stores to the current syntax sugar. Behind the scenes, the store contract is still basically an event dispatcher with only one event, it's just hidden by the compiler. If we don't use a compiler, we may have to subscribe by hand, but on the other hand we won't be caught on an update treadmill when the framework devs discover observables (sorry, "runes") four years later.

Personally, I don't think it actually matters very much how you get notified for low-level re-renders — if you wanted to argue that a modern framework uses signals for granular reactivity, that's fine by me — but what I like about standardizing on EventTarget for news about high-level concerns is that it's already familiar, it's free with the browser, and it encourages us to think about changes to data more coherently than "a single value" or "a slice of a big state object."

Broadcast messages

The preoccupation with reducing everything to data transformation is a common blind spot in current front-end frameworks. Data is important, of course — I'm a firm believer in the Linus Torvalds maxim that good programmers worry more about structure than they do code — but sometimes something happens that doesn't create a notable change, or it creates different kinds of changes in different places, or it's a long-running process that we just want to keep an eye on. Not everything is a noun! We need verbs, too!

When I worked on Caret from 2014 to 2018 or so, I was learning a lot about how to structure a relatively large, complex application — certainly it was the biggest thing I'd ever built on my own. But one of the best decisions I made early on was to have different parts of the editor communicate with each other using command messages sent over a central pub/sub (publish and subscribe) channel.

This had a lot of advantages. Major systems didn't need to be tightly coupled together, especially around menus and keyboard shortcuts, which effectively transformed streams of input events into higher-level commands. Some web apps may be able to pretend that the real work is safely isolated from side effects and statefulness, but a programmer's text editor has to deeply care about making input both easy and extensible. And as Caret went from a basic Notepad.exe replacement to a much more full-featured editor, its vocabulary of commands expanded naturally.

Take live settings, for example: Caret saved user preferences in JSON "files," which were persisted to Chrome's synchronized storage. When these changed, the settings provider would send an "init:restart" announcement over the command bus, and modules that used these files would reload their configuration to match. Importantly, the provider did not need to know which systems were listening, or what specific options they cared about (if any). The command was explicit, auditable, and self-explanatory, as opposed to a reactive framework where the settings object changes and a half-dozen other modules spontaneously reload themselves.

Like our subscribable store, the message channel is a subclass of EventTarget. It doesn't retain a value, but it can have a method to simplify the event creation and dispatch process. Instantiate that class, export it from the module, and import it anywhere you want to listen to messages.

class MessageBus extends EventTarget {
  broadcast(type, detail) {
    var e = new CustomEvent(type, { detail });
    this.dispatchEvent(e);
    return e;
  }
}

export const channel = new MessageBus();

I recommend namespacing (and probably defining constants for) your type strings early, since they're going to be sent far and wide: "change" or "update" isn't very useful when lots of things could be changing/updating, but "session:saved" (with the filename attached to the event detail) means you're less likely to collide with other messages, especially on a team.

The main thing I regret from Caret was not having a way for event consumers to send values back to the broadcaster directly. There was an optional callback in the event emitter code, but it was awkward to use, especially if multiple listeners wanted to return values. If I were building it now, I would imitate the Service Worker API and offer a respondWith() method on events:

class RespondableEvent extends Event {
  #responses = [];
  
  constructor(type, data) {
    super(type);
    this.data = data;
  }
  
  respondWith(response) {
    this.#responses.push(response);
  }
  
  // make responses add-only and async
  get responses() {
    return Promise.all(this.#responses);
  }
}

Listeners that need additional time to prepare can respond with a Promise instead of a direct value, meaning that this also doubles as a waitUntil() method. On the other end, the broadcasting module holds onto a reference to the event, and checks to see if it needs to take further action. In Caret, this would have been really useful for providing extension points like language servers or build automation:

var e = new RespondableEvent("file:beforesave", fileContents);
channel.dispatchEvent(e);
// check to see if any plugins responded
var annotations = await e.responses;
// add annotations to the editor control
for (var annotation of annotations) {
  /* ... */
}

Scenarios where asynchronous event responses are necessary are rare, but when you need them, they're invaluable, and this design doesn't add any overhead when not used.

Software as Metaphor

Conway's Law in software development says that the systems designed by an organization are a reflection of its communication structures. I would take that further: the systems we design are, at least a little, a reflection of the way we want the world to work. Part of the reason I like using event-driven architectures is because they effectively create chatty little communities within the program — colonial organisms, like a Portuguese man o' war (though hopefully less dangerous) — and for all my misanthropic tendencies, I do still believe we live in a society.

More importantly, this is a way to think about high-level architecture, but it does not have to be the single method for every part of the app. As I said at the start, I'm suspicious of all-encompassing framework paradigms. If our software is a microcosm of our ideal environment, there's something worrying about reducing all processes to a "pure" transform of input and output.

Web components are not a complete framework in the way that React (or Vue, or Svelte) is. They're just one layer of an application. You can see that as a flaw, but I think it's an opportunity to go back to software that has texture to it, where the patterns that are used at the top level do not have to be the exact same as those used in individual modules, or at lower layers of the stack, if it turns out that they're not well-suited to the problem at hand.

And to be fair, outside of React we see a lot more experimentation with forms of coordination that aren't tied so tightly to one particular VDOM. Preact's signals, for example, provide a level of reactivity that can be used anywhere, and which you could easily integrate with the architecture I've described (listeners updating signal values, and effect functions dispatching events).

I don't think web components are the only reason for that, but I do think their existence as a valid alternative — a kind of perpetual competition to framework code, in which you can get started without a single import statement or npm install — means that there's greater incentive to build primitives that are interoperable, not locked to a single ecosystem.

In the context of that reset, events make sense to me in terms of organizing my code, but I'm hopeful that we'll soon find other techniques re-emerging from the vast prior art of UI toolkits, both native and on the web. There's a lot more out there than closures and currying, and I can't wait to see it.

Past - Present