SPA Tools: Rendering

In this series of posts we'll walk through building fundamental SPA tools, parts of a 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 API, 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 that changes somewhat often, though technically the browser does the 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 easily to particular elements, 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.replaceChildren 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.replaceChildren(myApp(appState));

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

An issue thats still present however is we're creating a new event listener every render, however the old listeners are technically destroyed when the DOM node its attached to gets replaced.

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 that just before actually re-rendering the DOM we can look at the currently focused element, and save details about which element, its text, and any selected part to then attempt to match and restore after the render.

const defaultItems = [
  { id: 'para-1', type: 'p',        text: 'Select some text in this paragraph.' },
  { id: 'input-1', type: 'input',   value: 'Type here, then select part of it.' },
  { id: 'ta-1',    type: 'textarea', text: 'Type in this textarea and select some of it.' },
];

const updatedItems = [
  { id: 'para-1', type: 'p',        text: 'Select some text in this paragraph (re-rendered).' },
  { id: 'input-1', type: 'input',   value: 'Potential updated value 1' },
  { id: 'ta-1',    type: 'textarea', text: 'Potential updated value 2' },
];

function createEl(item) {
  let el;
  switch (item.type) {
    case 'input':
      el = document.createElement('input');
      el.type = 'text';
      el.value = item.value;
      break;
    case 'textarea':
      el = document.createElement('textarea');
      el.rows = 3;
      el.value = item.text;
      break;
    default:
      el = document.createElement('p');
      el.contentEditable = 'true';
      el.textContent = item.text;
  }
  el.dataset.id = item.id;
  return el;
}

function render(container, items) {
  container.replaceChildren(...items.map(createEl));
}

function saveState(container) {
  const state = {
    values: new Map(),   // every input/textarea value
    focus:  null,        // data-id of focused element
    selection: null,     // selection details for focused element
  };

  // 1. Save all modified values
  container.querySelectorAll('input, textarea').forEach(el => {
    if (el.dataset.id) state.values.set(el.dataset.id, el.value);
  });

  // 2. Save focus + selection
  const active = document.activeElement;
  if (active.parentElement === container && active.dataset.id) {
    state.focus = active.dataset.id;

    if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') {
      state.selection = {
        kind: 'input',
        start: active.selectionStart,
        end:   active.selectionEnd,
      };
    } else {
      const sel = window.getSelection();
      if (sel.rangeCount > 0) {
        const range = sel.getRangeAt(0);
        if (active.contains(range.startContainer) &&
            active.contains(range.endContainer)) {
          state.selection = {
            kind: 'range',
            start: charOffset(active, range.startContainer, range.startOffset),
            end:   charOffset(active, range.endContainer, range.endOffset),
          };
        }
      }
    }
  }

  return state;
}

// Restore state
function restoreState(container, state) {
  // 1. Restore values on all inputs/textareas
  state.values.forEach((value, id) => {
    const el = container.querySelector(`[data-id="${id}"]`);
    if (el) el.value = value;
  });

  // 2. Restore focus
  if (!state.focus) return;
  const el = container.querySelector(`[data-id="${state.focus}"]`);
  if (!el) return;
  el.focus({ preventScroll: true });

  // 3. Restore selection
  if (!state.selection) return;
  const s = state.selection;

  if (s.kind === 'input') {
    el.selectionStart = Math.min(s.start, el.value.length);
    el.selectionEnd   = Math.min(s.end,   el.value.length);
  } else {
    const range = document.createRange();
    range.setStart(...charToNodeOffset(el, s.start));
    range.setEnd(...charToNodeOffset(el, s.end));
    const sel = window.getSelection();
    sel.removeAllRanges();
    sel.addRange(range);
  }
}

// Re-render (save -> replace -> restore)
function reRender(container, items) {
  const state = saveState(container);
  render(container, items);
  restoreState(container, state);
}

// Text selection helpers
function charOffset(root, node, offset) {
  let count = 0;
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
  while (walker.nextNode()) {
    if (walker.currentNode === node) return count + offset;
    count += walker.currentNode.length;
  }
  return count + offset;
}

function charToNodeOffset(root, charIndex) {
  let remaining = charIndex;
  const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
  while (walker.nextNode()) {
    const n = walker.currentNode;
    if (remaining <= n.length) return [n, remaining];
    remaining -= n.length;
  }
  const last = root.lastChild;
  return [last, last ? last.length : 0];
}


// Initial render
const container = document.getElementById('container');
render(container, defaultItems);

// After some time something triggers a re-render
setTimeout(() => reRender(container, updatedItems), 5000);

Whilst this works there is the issue of higher pressure on the JS engines garbage collector, as all previous elements and event listeners are thrown away and re-created every render. This might not be too big of an issue if renders are infrequent, or if the DOM tree is small, but for large UI thats realtime or highly interactive it makes more sense to keep elements around.

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 represent 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 HTML or DOM elements, produces a Virtual DOM (VDOM), essentially a JavaScript object describing the DOM structure it wants to produce.

That VDOM gets compared against the 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 slow, so other frameworks/libraries opt for a more surgical or "Fine-Grained" approach.

Fine-grained updates

Instead of generating the whole UI every time, the idea is to only care about which parts of the DOM are likely to be dynamic, and only apply changes to those parts.

An approach to doing this is called Signals, and is used by Solid, Vue, Angular, VanJS and many more.

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.

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

So Signals allow us to take the problem of we have a whole UI but what changed? And cut that down to we know this small part changed, how can we update it. Depending on the approach to then make that update we can either swap out DOM elements or patch in the changes. VanJS for example uses the .replaceChildren approach similar to the earlier example.

An alternative approach

There's another approach used by lit-html (the rendering engine under the hood of Lit) that keeps the re-render everything concept that React/Preact use, but avoids VDOM diffing.

It leans on tagged template literals, a built in concept in JavaScript to understand which parts of a UI are dynamic and which parts are fixed, it then uses the Template element to render inert DOM and clones chunks where needed.

You call render whenever you want it to reproduce the UI and it only patches those dynamic parts of the DOM.

It uses DOM comments as markers to help it understand where exactly in the DOM dynamic parts are and then only updates those dynamic parts.

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).

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

Its clear that for most reasonably sized applications its not enough to naively destroy and re-render the DOM everytime you need to update.

Whilst this happens in a classic page by page server rendered world, for an SPA there's an expectation of maintaing a "live" system you're currently looking at.

For the Signals based systems, they have a first render step and only re-render small parts, you have to think more about how you wire up the global reactive state into individual Signals to only cause updates where expected.

Lit-html on the other hand maintains the immediate mode style API of re-render everything whenever you want and it will reuse the underlying DOM where possible, but does it in a way that avoids inefficient tree walking and diffing.

Based on that i'm leaning more toward the lit-html approach which is available as a standalone package.

Final weigh in for rendering engine:

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.