Stateful DOM Rendering

work in progress
NB:

This article ended up being published via Smashing Magazine under a different title, improved thanks the editorial expertise of Geoff Graham. This draft version is archived here for historical purposes.

It’s well-established that the web has issues: From user-hostile UI patterns and twisted search results to sluggish performance and battery-draining bloat. Much of that is caused by questionable technology choices, not least in the realm of client-side JavaScript. In the interest of furthering our collective understanding of this self-inflicted quagmire, let’s examine one small-but-significant part where developers take the reins: Painting pixels on the screen.

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 are also good reasons for front-end developers to choose a framework, or library for that matter: Dynamically updating web UIs can be tricky in non-obvious ways. Let’s investigate by starting from the beginning, going back to first principles.

Markup Categories

Everything on the web starts with markup, i.e. HTML. Markup structures can roughly be divided into three categories:

For example, an article’s header might look like this:

<header>
    <h1>Hello World</h1>
    <small>123 backlinks</small>
</header>

Here variable parts are highlighted, with the backlinks counter perhaps being continuously updated via client-side scripting. Everything else remains identical boilerplate for all our articles.

This particular article now focuses on the third category.

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 names with a color swatch and the corresponding value. Users should be able to search through names as well as toggle between hexadecimal color codes and RGB triplets. We can create an inert skeleton with just 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 constitues a complete application or merely an island embedded within an otherwise static or server-generated HTML document.

Given our predilection for vanilla JavaScript – first principles, remember? – we start with the browser’s built-in APIs as DOMinintended:

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;
}

renderPalette generates our list of colors. Let’s add the 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 ’em in a custom element:

import { COLORS } from "./colors.js";

customElements.define("color-browser", class ColorBrowser extends HTMLElement {
    colors = [...COLORS];

    connectedCallback() {
        this.append(
            renderControls(),
            renderPalette(this.colors)
        );
    }
});

Henceforth, <color-browser> anywhere in our document will expand to generate the entire UI. This implementation is somewhat declarative1, with DOM structures being created by composing of a variety of straightforward markup generators.

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;
        }
    }
}

Whenever one of our form fields changes, we now update the corresponding instance variable (sometimes called one-way data binding). Alas, while we can now change internal state2, that’s not reflected anywhere in the UI yet.

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 – think switching to radio buttons for color representations – now needs to take into account this other piece of code; action at a distance. Expanding this component’s contract to include field names could alleviate such concerns.

We’re now faced with a choice: Do we

  1. reach into our previously created DOM to modify it, or
  2. recreate it while incorporating new state?

Rerendering

Since we’ve already defined our markup composition in one place, let’s start with the latter:

class ColorBrowser extends HTMLElement {
    // …

    connectedCallback() {
        this.#render();
        this.addEventListener("input", this);
        this.addEventListener("change", this);
    }

    handleEvent(ev) {
        // …
        this.#render();
    }

    #render() {
        this.replaceChildren();
        this.append(renderControls(), renderPalette(this.colors));
    }
}

So we’ve moved all rendering logic into a dedicated method3 and now invoke it whenever state changes, instead of just once on startup.

To filter colors based on the search query, we can turn colors into a getter:

class ColorBrowser extends HTMLElement {
    query = null;
    rgb = false;

    // …

    get colors() {
        let { query } = this;
        if(!query) {
            return [...COLORS];
        }

        return COLORS.filter(color => color.name.toLowerCase().includes(query));
    }
}

This now produces interesting/annoying behavior:

Entering a query seems impossible as the input field loses focus after any change, with the input field remaining empty. However, entering a less common character (e.g. “v”) makes it clear that something is happening: The list of colors does change.

This is because our DIY approach here is quite crude: #render erases and recreates the DOM wholesale with each change. Of course discarding existing DOM nodes also resets corresponding state: form fields’ value and focus as well as scroll position. That’s no good!

Incremental Rendering

A data-driven UI seemed like a nice idea: Markup structures are defined once and re-rendered at will, based on whatever the current state is. Yet clearly our component’s explicit state is insufficient; we need to reconcile it with the browser’s implicit state when re-rendering.

Sure, we might attempt to make that implicit state explicit and incorporate it into our data model, like including fields’ value or checked properties. However, that still leaves focus management, scroll position and myriad details we probably haven’t even thought of (typically including accessibility features). Before long, we’d effectively be recreating the browser!

Or we might try to identify which parts need updating and leave the rest of the DOM untouched. Unfortunately, that’s far from trivial though – this is where libraries like React entered the scene 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, they introduced mechanisms5 to provide granular, incremental DOM updates instead of recreating DOM trees from scratch – both to avoid these state issues and to improve efficiency/performance6.

The gist is: 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, we as application developers can reach into the DOM and modify only those parts that need updating.

Unfortunately, that typically leads to even tighter coupling, with interrelated logic being spread all over our application and targeted routines inevitably violating components’ encapsulation. Things become even more complicated when we take into account increasingly complex UI permutations (think edge cases, error reporting etc.). Those are the very issues the aforementioned libraries had hoped to eradicate.

In our case, that would mean finding and hiding palette entries that don’t match our query – possibly replacing the list with a substitute message if no matching entries remain. We’d also have to toggle entries’ 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 {
    // …

    handleEvent(ev) {
        // …
        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() {
        // …
        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 their respective state, we’d also need to know where to inject error messages. While reaching into renderPalette was bad enough, here instead of crossing a single component’s boundary, we’d 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 yet more layers and indirections: Keeping on top of all those interconnections becomes all but impossible – commonly devolving into a big ball of mud where nobody dares touch anything anymore.

Conclusion

There appears to be a glaring omission in standardized browser APIs: Our preference for dependency-free vanilla-JavaScript solution 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 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 encumberance to end users – especially when combined with progressive enhancement.

I don’t wanna leave you hanging though, so I’ve tricked our vanilla-JS implementation to mostly do what we expect it to: