Hiccup: Representing the DOM with arrays

Complaints and conversations about DOM interactivity (particularly about frameworks) are very much in vogue. I'd like to avoid too much pontification about my experience with JS frameworks, likes and dislikes, the industry as a whole, pile-ons against React + hooks, etc., since this has been debated to death on sites like Hacker News (which I have regretably participated in).

Instead, I'd like to focus on a fun, quiet corner of JS DOM hackery that I enjoy working with and which has a nice history.

What is hiccup?

Hiccup is a very simple way to represent a DOM hierarchy. Well, it can represent any hierarchy really, and in many ways is so simple that it is presumably used in many contexts in many codebases without such a name.

The basic idea is to take a DOM hierarchy, such as:

<div id="men">
  <p>hello world!</p>
<div>

And represent it as a nested array:

["div", {id: "men"}, [
  ["p", {}, ["hello world!"]], // children of "men"
]];

The idea and name come from James Reeve's Clojure project, and it's easy to see how this representation could be valuable in Clojure with its Lispy style.

This idea is built upon Bruno Fidelis' JavaScript project hiccupjs, to which I made small contributions and have since mutated in various ways across a few recent small projects (including the text editor project which I published at the same time as the post you are reading).

Writing utilities to apply hiccup to the DOM

Given a hiccup array, there are only a small number of things we need to be able to do to use it on the DOM from JS.

The result is a set of simple utilities which, together, enable declarative changes to the DOM and the ability to patch those changes. Something beautiful about the result when trying to reduce these problems to their most minimal is that you eventually arrive at something almost archetypal, where the inherent simplicity in the tree structure of the DOM shines through.

Patching element attributes

The first function (and probably the one which is most useful as a standalone utility) is for patching element attributes. That is, given a DOM element and an object (a mapping of attribute name to value), update the DOM element's attributes to match the object.

/**
 * Applies a dictionary representation of the HTMLElement properties
 * to the element.
 */
const updateAttrs = (el, attrs) => {
  const [, prevAttrs] = el._hic || [];

  Object
    .entries(attrs)
    .forEach(([k, v]) => { 
      if (prevAttrs && typeof prevAttrs[k] === "function") {
        el.removeEventListener(k, prevAttrs[k]);
      }

      if (typeof v === "function") {
        el.addEventListener(k.toLowerCase(), v);
      } else {
        // Weird specific case. The view doesn't update if you do el.setAttribute('value', 10) on an input element.
        if (k === 'value' || k === 'disabled') {
          el[k] = v;
          return;
        }
        
        const asElement = el;
        if (asElement.getAttribute(k) !== v) {
          asElement.setAttribute(k, v);
        }
      }
    })

  return el;
}
   

Recursive rendering of function elements

With one small addition, we can add some idea of a custom component too: allow functions as the first entry in a hiccup array. For example:

const StyledParagraph = ({ children }) =>
    ["p", { class: "my_class" }, children];

const myStyledElement = [StyledParagraph, {}, "hello"];

We can write a simple function to expand myStyledElement into nested hiccup that only contains "real" DOM elements (this is sort of equivalent to the React "render" step):

/**
  Given some hiccup, resolve any components to their resulting DOM only
  hiccup. That is, only hiccup elements with lower case tag names should remain.
  
  This entails running the components with their attributes.

  Code to maintain unique keys has been removed from the below for simplicity,
  as well as Typescript types.
*/
export const render = (hic) => {
 if (!isHic(hic)) {
   return hic;
 }

 const [tag, attrs, children] = hic;
 const renderedChildren = children
   .map((child: HicType) => {
     return render(child);
   });

 if (typeof tag === "function") {
   const renderResult = tag({ ...attrs, children: renderedChildren });
   return render(renderResult);
 }

 return [tag, attrs, renderedChildren];
};

Applying hiccup to the DOM

All that remains is two functions. One to update the children of a DOM element, and another to tie everything together. This is a little complex but there's not a great deal to say about it:

/**
   Given an element and an array of children, make the array of elements
   be the children of the element, while being minimally destructive of
   existing children. This is so that state associated with element instances
   is not damaged (for example, the element does not lose focus).
 */
const updateChildren = (el, newChildren) => {
  for (let i = newChildren.length - 1; i >= 0; i--) {
    const currChild = newChildren[i];
    const desiredNextSibling = newChildren[i+1] || null;
    const existingNextSibling = currChild.nextSibling;
    if (desiredNextSibling !== existingNextSibling || !el.contains(currChild)) {
      el?.insertBefore(currChild, desiredNextSibling);
    }
  }

  while (el.childNodes.length > newChildren.length) {
    el?.removeChild(el.childNodes[0]);
  }

  return el;
}

/**
   Given some HTML element, update that element and its children with the hiccup.
   This preserves existing HTML elements without removing and creating new ones.
*/
export const apply = (hic, el) => {
  let result = el;

  if (!hic && hic !== "") {
    return null;
  }
  
  // Basically leaf text nodes. Early return because they cannot have children
  if (!isHic(hic)) {
    if (el?.nodeType !== 3) {
      return document.createTextNode(hic);
    }

    if (el.textContent !== hic) {
      el.textContent = hic;
    }

    return el;
  }

  const [prevTag, prevAttrs] = el?._hic || [];
  const [tag, attrs] = hic;

  // New element case. When creating elements we need to specify the correct namespace
  if (prevTag !== tag || !result) {
    const currentNS = attrs.xmlns || (tag === 'svg' ? 'http://www.w3.org/2000/svg' : 'http://www.w3.org/1999/xhtml');
    result = document.createElementNS(currentNS, tag);
  }

  // Update element with attrs (we defined this earlier)
  updateAttrs(result, attrs);

  // Store the hic used as a "hidden" property of the DOM element.
  result._hic = hic;

  // Apply each child and assign as a child to this element.
  const children = isHic(hic) ? hic[2] : [];
  const newChildren = children
    .filter(c => c)
    .map((child, idx) => {
      const existingNode = el?.childNodes[idx];
      return apply(child, existingNode as TaggedElement);
    });

  updateChildren(result, newChildren);

  if (el !== result) {
    el?.parentNode?.replaceChild(result, el!!);
  }

  return result;
}

And with that, we have a set of tools that make it very straight forward to interact with the DOM, and taking up only around 1kb minified (or 600 bytes gzipped!). I've been thinking of these utilities as a kind of jQuery for the modern age.

Addendum: JSX

With the help of a bundler that supports JSX, we can support it with the inclusion of one more function:

/**
    Conform to the jsx factory signature to produce hiccup.
 */
 export const hic = (name, options, ...children): HicType => {
   const flatChildren = children.reduce((acc, child) => {
     if (Array.isArray(child) && !isHic(child)) {
       acc.push(...child);
     } else {
       acc.push(child);
     }
     return acc;
   }, []);
 
   return [name, options || {}, flatChildren];
 }
 

Which allows us to rewrite our custom element function:

const StyledParagraph = ({ children }) =>
  <p class="my_class">{ children }</p>;

const myStyledElement = [StyledParagraph, {}, "hello"];

And then with something like babel or esbuild's jsxFactory we just need to supply the hic function as the factory.

Feedback

Let me know if you have any feedback by sending me an email at craig@crwi.uk. If there's any way you can see simple improvements or reductions, or just have any thoughts about this approach at all.