Web Tools: Rendering

In this series of posts we'll walk through building fundamental web tools, parts of an SPA (Single Page App) that can be reused across projects, the focus will mainly be on demystifying how SPA features and functionality (often provided by libraries or frameworks) are working under the hood, but a nice side effect is having full control of the software you're building.

Rendering

Following on from Reactive State the next tool ideally will help to show something on screen, but not only that, to keep the screen updated matching the current state.

The idea that the UI is a function of state, or UI = f(state) is not new, servers have been doing this forever. They hold the state within a database and every GET request produces the UI (streamed as HTML) from the current state.

Actions are sent from the client side in the form of HTTP GET via links, or HTTP POST via forms to update or retrieve the latest UI/state.

Once the HTML string has been recieved by the browser its no longer static and instead gets converted into a document object model (DOM). For our SPA, this is what we need to update to ultimately update the screen.

When thinking about constructing UI there's two general ideas, immediate mode, and retained mode.

Immediate mode is what we described above, the UI is a function of state, how often that function needs to run depends on what kind of system we want to build, in games for example it tends to run every frame. The fundamental idea is we re-run the UI function and it produces a consistent output based on a consistent state.

Retained mode on the other hand you create data (or objects) representing the pieces of UI and then tell them how to act. To produce a new UI you tend to find and tell the pieces how to change to match your state.

Intuatively its much easier to think in an immediate mode style, based on this input i'll get this output, its also much easier to test, provide a known state and assert the output.

State_A --> UI_A
State_B --> UI_B

The DOM

Unfortunately the DOM uses a retained mode style, you do things like document.createElement('h1') and now you have a heading element in memory you need to manage and keep updated.

One way to force an immediate mode style on to the DOM is to use .innerHTML. We can have a UI function generate a HTML string via state, and then apply that string to a container element via container.innerHTML = HTMLString. The browser does its usual job of parsing the HTML and creating the DOM elements to represent that on screen.

let appState = 'Hello world';

const myApp = (state) => {
  return `<h1>${state}</h1>`;
}

const container = document.querySelector('#container');

// Initial render.
container.innerHTML = myApp(appState);

// After some time we update the state and re-render our app UI.
setTimeout(() => {
  appState = 'Jason';
  container.innerHTML = myApp(appState);
}, 3000);

There's a couple of issues with this, firstly its a bit wasteful especially for large UI, though technically the browser does this same thing every full page navigation, so throwing away DOM and re-creating isn't as big of a deal for browsers these days. Secondly and probably the bigger issue is with this style of generating DOM you can't attach event listeners, because the DOM elements are created at the point of setting innerHTML rather than at the point of generating myApp.

To get around this we can use DOM APIs to create each element so we have DOM references to set up our own event handlers.

We return a DOM structure from myApp and run container.appendChild to apply it to the screen.

let appState = 'Hello world';

const myApp = (state) => {
  const h1 = document.createElement('h1');
  h1.innerText = state;
  h1.addEventListener('click', (e) => {
    console.log('you clicked the heading');
  })
  return h1;
}

const container = document.querySelector('#container');

// Initial render.
container.appendChild(myApp(appState));

// After some time we update the state and re-render our app UI.
setTimeout(() => {
  appState = 'Jason';
  container.innerHTML = ''; // Clear the children first.
  container.appendChild(myApp(appState));
}, 3000);

An issue thats still present however is we're creating a new event listener every re-render, the old listeners are destroyed when the old DOM node goes out a scope (after container.innerHTML = ''). There's also other things we lose from this approach, re-establishing any currently selected text, the current input data, and the currently focused element, all these tend to be anchored to existing DOM elements that get destroyed when we re-render.

To fix the event lister issue we can set event listeners on the container element, and map events that bubble up from elements below. Generally this is a good technique to avoid attaching an event listener to every element that needs to be clicked, such as a large list.

A way to fix input text, focus, and text selection is to retrieve all these values before clearing container.innerHTML and applying them to the new DOM nodes during the re-render, but this feel like we're fighting a lot with the retained mode style of the DOM.

Sync existing DOM

Another technique to fix this is to take the current DOM tree and figure out which parts of it need to change to sync with the updated state. A famous approach from React and Preact is Virtual DOM Diffing.

With VDOM style syncing the idea is your immediate mode function instead of producing an HTML string, produces a Virtual DOM (VDOM), essentially a JavaScript object describing the DOM structure it wants to produce.

Then it compares that against a previous output to see which parts differ, and ultimately applies the differences to update the real DOM.

It turns out however that comparing two large tree structures for differences can be expensive so other frameworks/libraries opt for a more surgical or "Fine-Grained" approach.

Instead of generating the whole UI every time, they understand which parts of the UI are likely to be static, and which parts are likely to be dynamic, based on this they can focus only on the dynamic parts, skipping expensive diffing.

A popular implementation of this is called Signals, used by Solid, Vue, Angular, Svelte, and optionally in Preact. Here's a quick implementation:

// We track the current function being run, so we can subscribe it
// and re-run it when the Signal state changes.
const currentFunction = [];
const queue = new Set();
let isScheduled = false;

function processQueue() {
  for (const effect of queue) {
    effect();
  }
  queue.clear();
  isScheduled = false;
}

function createSignal(value) {
  const subscriptions = new Set();

  return {
    get value() {
      const currentlyRunningFunction = currentFunction[currentFunction.length - 1];
      if (currentlyRunningFunction) subscriptions.add(currentlyRunningFunction);
      return value;
    },
    set value(newValue) {
      if (value === newValue) return;
      value = newValue;

      for (const sub of subscriptions) {
        queue.add(sub);
      }

      if (!isScheduled) {
        isScheduled = true;
        queueMicrotask(processQueue);
      }
    }
  };
}

function createEffect(fn) {
  const execute = () => {
    currentFunction.push(execute);
    try {
      fn();
    } finally {
      currentFunction.pop();
    }
  };
  execute();
}

// Usage code.

const appState = createSignal('Hello world');

const myApp = (stateSignal) => {
  const h1 = document.createElement('h1');
  h1.addEventListener('click', (e) => {
    console.log('you clicked the heading');
  });

  createEffect(() => {
    // This anonymous function will automatically 
    // re-run when the signal state updates.
    h1.textContent = stateSignal.value;
  });

  return h1;
};

const container = document.querySelector('#container');

// Initial render.
// In this scenario myApp only ever runs once
// wiring up the signal to update its DOM.
container.appendChild(myApp(appState));

setTimeout(() => {
  // The createEffect Signal will automatically update the DOM.
  appState.value = 'Jason';
}, 3000);

You might notice that the Signal implementation looks similar to our reactive state from the previous post. We take a function and add it to a subscribe Set() so we can re-run them later once we detect a value has changed. So you can think of Signals as many tiny reactive state systems all firing and updating parts of UI when their state updates.

This is good, but if we want to combine with our app level reactive state from the previous post there's mapping required to go from our app level reactive state and wire that into all the required Signals.

For example:

// 1. We import the store from our reactive state.
import { createStore } from './store.js';

const currentFunction = [];
const queue = new Set();
let isScheduled = false;

function processQueue() {
  for (const effect of queue) {
    effect();
  }
  queue.clear();
  isScheduled = false;
}

function createSignal(value) {
  const subscriptions = new Set();

  return {
    get value() {
      const currentlyRunningFunction = currentFunction[currentFunction.length - 1];
      if (currentlyRunningFunction) subscriptions.add(currentlyRunningFunction);
      return value;
    },
    set value(newValue) {
      if (value === newValue) return;
      value = newValue;

      for (const sub of subscriptions) {
        queue.add(sub);
      }

      if (!isScheduled) {
        isScheduled = true;
        queueMicrotask(processQueue);
      }
    }
  };
}

function createEffect(fn) {
  const execute = () => {
    currentFunction.push(execute);
    try {
      fn();
    } finally {
      currentFunction.pop();
    }
  };
  execute();
}

// Usage code.

const initialState = {
  greeting: 'Hello world'
}

// 2. Create our app level store.
const appStore = createStore(initialState);

// 3. Create a Signal mapping its initial value to
//    one of our reactive state values.
const greetingSignal = createSignal(appStore.state.greeting);

// 4. Subscribe to our app level state to be
//    notified when it updates.
appStore.subscribe((newState) => {
  const newGreeting = newState.greeting;

  if (greetingSignal !== newGreeting) {
    greetingSignal.value = newGreeting;
  }
});

const myApp = (stateSignal) => {
  const h1 = document.createElement('h1');
  h1.addEventListener('click', (e) => {
    console.log('you clicked the heading');
  });

  createEffect(() => {
    h1.textContent = stateSignal.value;
  });

  return h1;
};

const container = document.querySelector('#container');

container.appendChild(myApp(greetingSignal));

setTimeout(() => {
  // 5. Update our app level state and let that flow
  //    through the subscriber -> Signal -> UI.
  appStore.state.greeting = 'Hello app';
}, 3000);

There is another technique however that lit-html (the DOM library under the hood of Lit) uses. It maintains the top down re-render everything concept that React/Preact use, but avoids VDOM diffing by utilising tagged template literals.

Tagged template literals are a JavaScript feature that takes a string and provides your tag function with all the static parts, and all the dynamic parts (defined within ${}).

Lit-html provides a html tag function to describe your UI and a render function to render and mount to a container.

import { html, render } from 'lit-html';

const myApp = (state) => {
  return html`
     <h1>Welcome ${state.name}</h1>
  `;
}

// Run this every time we need to re-render.
render(myApp(appState), document.querySelector('#container'));

We can subscribe to our app state the same as we did for our reactive state, and call render again with the myApp(appState).

On the surface this might look similar to our container.innerHTML immediate mode example above, but under the hood its using DOM primitives to avoid any diffing, instead doing a surgical update similar to Signals, but in its own unique way.

Instead of just generating a string, the result of the html'' tagged template literal is a TemplateResult, which contains all the parts of the string that are static, and all the parts that are dynamic, any tagged template literal function gets this automatically.

It takes the TemplateResult and uses an HTML Template element under the hood to generate DOM with markers placed wherever the dynamic parts are. It uses the static part of TemplateResult which is consistently static no matter how many times you run the function as a key to save this HTML Template. During a re-render it uses the static part to lookup the DOM, and the markers to know exactly where it needs to update surgically.

So the heavy lifting is done on the first call to render, then all subsequent calls just surgically update the DOM without having to do any diffing.

An example of integrating with the reactive state system:

import { createStore } from './store.js';
import { html, render } from 'lit-html';

const store = createStore(initialState);

const container = document.querySelector('#container');

const clickHandler = (e) => {
  console.log('you clicked the heading');
}

const myApp = (state) => {
  return html`
     <h1 @click=${clickHandler}>Welcome ${state.name}</h1>
  `;
}

store.subscribe((newState) => {
  // Using our existing state to render our UI
  render(myApp(newState), container);
});

// Initial render
render(myApp(appState), container);


// When we update state the UI will sync automatically
store.state.name = 'Jason';

Final thoughts

Whilst its possible to write our own DOM syncing functionality as the Signals example shows, I find for this type of functionality I prefer to reach for lit-html.

One reasons for this is how its API feel most like the simple innerHTML style, but under the hood its handling things efficiently.

Another is how consistent its core API is, which has remained stable for over 6 years now, as well as its overall size which gives a lot of bang per kb.

Final weigh in for lit-html:

7.1kb uncompressed
3.1kb gzip compressed
2.9kb brotli compressed

Hopefully this has shown the different techniques used to synchronise the DOM with state. We'll continue to look at building different SPA functionality in future posts.