Vanilla JavaScript, Libraries, And The Quest For Stateful DOM Rendering
Frederik Dohr 2024-02-22T18:00:00+00:00
2024-04-06T16:05:18+00:00
In his seminal piece “The Market For Lemons”, renowned web crank Alex Russell lays out the myriad failings of our industry, focusing on the disastrous consequences for end users. This indignation is entirely appropriate according to the bylaws of our medium.
Frameworks factor highly in that equation, yet there can also be good reasons for front-end developers to choose a framework, or library for that matter: Dynamically updating web interfaces can be tricky in non-obvious ways. Let’s investigate by starting from the beginning and going back to the first principles.
Markup Categories
Everything on the web starts with markup, i.e. HTML. Markup structures can roughly be divided into three categories:
- Static parts that always remain the same.
- Variable parts that are defined once upon instantiation.
- Variable parts that are updated dynamically at runtime.
For example, an article’s header might look like this:
<header>
<h1>«Hello World»</h1>
<small>«123» backlinks</small>
</header>
Variable parts are wrapped in «guillemets» here: “Hello World” is the respective title, which only changes between articles. The backlinks counter, however, might be continuously updated via client-side scripting; we’re ready to go viral in the blogosphere. Everything else remains identical across all our articles.
The article you’re reading now subsequently focuses on the third category: Content that needs to be updated at runtime.
Color Browser
Imagine we’re building a simple color browser: A little widget to explore a pre-defined set of named colors, presented as a list that pairs a color swatch with the corresponding color value. Users should be able to search colors names and toggle between hexadecimal color codes and Red, Blue, and Green (RGB) triplets. We can create an inert skeleton with just a little bit of HTML and CSS:
Client-Side Rendering
We’ve grudgingly decided to employ client-side rendering for the interactive version. For our purposes here, it doesn’t matter whether this widget constitutes a complete application or merely a self-contained island embedded within an otherwise static or server-generated HTML document.
Given our predilection for vanilla JavaScript (cf. first principles and all), we start with the browser’s built-in DOM APIs:
function renderPalette(colors) {
let items = [];
for(let color of colors) {
let item = document.createElement("li");
items.push(item);
let value = color.hex;
makeElement("input", {
parent: item,
type: "color",
value
});
makeElement("span", {
parent: item,
text: color.name
});
makeElement("code", {
parent: item,
text: value
});
}
let list = document.createElement("ul");
list.append(...items);
return list;
}
Note:
The above relies on a small utility function for more concise element creation:function makeElement(tag, { parent, children, text, ...attribs }) { let el = document.createElement(tag); if(text) { el.textContent = text; } for(let [name, value] of Object.entries(attribs)) { el.setAttribute(name, value); } if(children) { el.append(...children); } parent?.appendChild(el); return el; }
You might also have noticed a stylistic inconsistency: Within the
items
loop, newly created elements attach themselves to their container. Later on, we flip responsibilities, as thelist
container ingests child elements instead.
Voilà: renderPalette
generates our list of colors. Let’s add a form for interactivity:
function renderControls() {
return makeElement("form", {
method: "dialog",
children: [
createField("search", "Search"),
createField("checkbox", "RGB")
]
});
}
The createField
utility function encapsulates DOM structures required for input fields; it’s a little reusable markup component:
function createField(type, caption) {
let children = [
makeElement("span", { text: caption }),
makeElement("input", { type })
];
return makeElement("label", {
children: type === "checkbox" ? children.reverse() : children
});
}
Now, we just need to combine those pieces. Let’s wrap them in a custom element:
import { COLORS } from "./colors.js"; // an array of `{ name, hex, rgb }` objects
customElements.define("color-browser", class ColorBrowser extends HTMLElement {
colors = [...COLORS]; // local copy
connectedCallback() {
this.append(
renderControls(),
renderPalette(this.colors)
);
}
});
Henceforth, a <color-browser>
element anywhere in our HTML will generate the entire user interface right there. (I like to think of it as a macro expanding in place.) This implementation is somewhat declarative1, with DOM structures being created by composing a variety of straightforward markup generators, clearly delineated components, if you will.
1 The most useful explanation of the differences between declarative and imperative programming I’ve come across focuses on readers. Unfortunately, that particular source escapes me, so I’m paraphrasing here: Declarative code portrays the what while imperative code describes the how. One consequence is that imperative code requires cognitive effort to sequentially step through the code’s instructions and build up a mental model of the respective result.
Interactivity
At this point, we’re merely recreating our inert skeleton; there’s no actual interactivity yet. Event handlers to the rescue:
class ColorBrowser extends HTMLElement {
colors = [...COLORS];
query = null;
rgb = false;
connectedCallback() {
this.append(renderControls(), renderPalette(this.colors));
this.addEventListener("input", this);
this.addEventListener("change", this);
}
handleEvent(ev) {
let el = ev.target;
switch(ev.type) {
case "change":
if(el.type === "checkbox") {
this.rgb = el.checked;
}
break;
case "input":
if(el.type === "search") {
this.query = el.value.toLowerCase();
}
break;
}
}
}
Note:
handleEvent
means we don’t have to worry about function binding. It also comes with various advantages. Other patterns are available.
Whenever a field changes, we update the corresponding instance variable (sometimes called one-way data binding). Alas, changing this internal state2 is not reflected anywhere in the UI so far.
2 In your browser’s developer console, check document.querySelector("color-browser").query
after entering a search term.
Note that this event handler is tightly coupled to renderControls
internals because it expects a checkbox and search field, respectively. Thus, any corresponding changes to renderControls
— perhaps switching to radio buttons for color representations — now need to take into account this other piece of code: action at a distance! Expanding this component’s contract to include
field names could alleviate those concerns.
We’re now faced with a choice between:
- Reaching into our previously created DOM to modify it, or
- Recreating it while incorporating a new state.
Rerendering
Since we’ve already defined our markup composition in one place, let’s start with the second option. We’ll simply rerun our markup generators, feeding them the current state.
class ColorBrowser extends HTMLElement {
// [previous details omitted]
connectedCallback() {
this.#render();
this.addEventListener("input", this);
this.addEventListener("change", this);
}
handleEvent(ev) {
// [previous details omitted]
this.#render();
}
#render() {
this.replaceChildren();
this.append(renderControls(), renderPalette(this.colors));
}
}
We’ve moved all rendering logic into a dedicated method3, which we invoke not just once on startup but whenever the state changes.
3 You might want to avoid private properties, especially if others might conceivably build upon your implementation.
Next, we can turn colors
into a getter to only return entries matching the corresponding state, i.e. the user’s search query:
class ColorBrowser extends HTMLElement {
query = null;
rgb = false;
// [previous details omitted]
get colors() {
let { query } = this;
if(!query) {
return [...COLORS];
}
return COLORS.filter(color => color.name.toLowerCase().includes(query));
}
}
Note:
I’m partial to the bouncer pattern.
Toggling color representations is left as an exercise for the reader. You might passthis.rgb
intorenderPalette
and then populate<code>
with eithercolor.hex
orcolor.rgb
, perhaps employing this utility:function formatRGB(value) { return value.split(","). map(num => num.toString().padStart(3, " ")). join(", "); }
This now produces interesting (annoying, really) behavior:
Entering a query seems impossible as the input field loses focus after a change takes place, leaving the input field empty. However, entering an uncommon character (e.g. “v”) makes it clear that something is happening: The list of colors does indeed change.
The reason is that our current do-it-yourself (DIY) approach is quite crude: #render
erases and recreates the DOM wholesale with each change. Discarding existing DOM nodes also resets the corresponding state, including form fields’ value, focus, and scroll position. That’s no good!
Incremental Rendering
The previous section’s data-driven UI seemed like a nice idea: Markup structures are defined once and re-rendered at will, based on a data model cleanly representing the current state. Yet our component’s explicit state is clearly insufficient; we need to reconcile it with the browser’s implicit state while re-rendering.
Sure, we might attempt to make that implicit state explicit and incorporate it into our data model, like including a field’s value
or checked
properties. But that still leaves many things unaccounted for, including focus management, scroll position, and myriad details we probably haven’t even thought of (frequently, that means accessibility features). Before long, we’re effectively recreating the browser!
We might instead try to identify which parts of the UI need updating and leave the rest of the DOM untouched. Unfortunately, that’s far from trivial, which is where libraries like React came into play more than a decade ago: On the surface, they provided a more declarative way to define DOM structures4 (while also encouraging componentized composition, establishing a single source of truth for each individual UI pattern). Under the hood, such libraries introduced mechanisms5 to provide granular, incremental DOM updates instead of recreating DOM trees from scratch — both to avoid state conflicts and to improve performance6.
4 In this context, that essentially means writing something that looks like HTML, which, depending on your belief system, is either essential or revolting. The state of HTML templating was somewhat dire back then and remains subpar in some environments.
5 Nolan Lawson’s “Let’s learn how modern JavaScript frameworks work by building one” provides plenty of valuable insights on that topic. For even more details, lit-html’s developer documentation is worth studying.
6 We’ve since learned that some of those mechanisms are actually ruinously expensive.
The bottom line: If we want to encapsulate markup definitions and then derive our UI from a variable data model, we kinda have to rely on a third-party library for reconciliation.
Actus Imperatus
At the other end of the spectrum, we might opt for surgical modifications. If we know what to target, our application code can reach into the DOM and modify only those parts that need updating.
Regrettably, though, that approach typically leads to calamitously tight coupling, with interrelated logic being spread all over the application while targeted routines inevitably violate components’ encapsulation. Things become even more complicated when we consider increasingly complex UI permutations (think edge cases, error reporting, and so on). Those are the very issues that the aforementioned libraries had hoped to eradicate.
In our color browser’s case, that would mean finding and hiding color entries that do not match the query, not to mention replacing the list with a substitute message if no matching entries remain. We’d also have to swap color representations in place. You can probably imagine how the resulting code would end up dissolving any separation of concerns, messing with elements that originally belonged exclusively to renderPalette
.
class ColorBrowser extends HTMLElement {
// [previous details omitted]
handleEvent(ev) {
// [previous details omitted]
for(let item of this.#list.children) {
item.hidden = !item.textContent.toLowerCase().includes(this.query);
}
if(this.#list.children.filter(el => !el.hidden).length === 0) {
// inject substitute message
}
}
#render() {
// [previous details omitted]
this.#list = renderPalette(this.colors);
}
}
As a once wise man once said: That’s too much knowledge!
Things get even more perilous with form fields: Not only might we have to update a field’s specific state, but we would also need to know where to inject error messages. While reaching into renderPalette
was bad enough, here we would have to pierce several layers: createField
is a generic utility used by renderControls
, which in turn is invoked by our top-level ColorBrowser
.
If things get hairy even in this minimal example, imagine having a more complex application with even more layers and indirections. Keeping on top of all those interconnections becomes all but impossible. Such systems commonly devolve into a big ball of mud where nobody dares change anything for fear of inadvertently breaking stuff.
Conclusion
There appears to be a glaring omission in standardized browser APIs. Our preference for dependency-free vanilla JavaScript solutions is thwarted by the need to non-destructively update existing DOM structures. That’s assuming we value a declarative approach with inviolable encapsulation, otherwise known as “Modern Software Engineering: The Good Parts.”
As it currently stands, my personal opinion is that a small library like lit-html or Preact is often warranted, particularly when employed with replaceability in mind: A standardized API might still happen! Either way, adequate libraries have a light footprint and don’t typically present much of an encumbrance to end users, especially when combined with progressive enhancement.
I don’t wanna leave you hanging, though, so I’ve tricked our vanilla JavaScript implementation to mostly do what we expect it to:
(yk)