DOM to JSON and back
Tags: craftcode, webdev, javascript, dom
Published: 2024-01-14 22:26:07

**We can persist and rehydrate DOM objects with simple vanilla JS.**

_5-6 minutes, 1332 words, 4th grade_

Here we will create **two simple but powerful JavaScript functions.**

The first, `jsToDom`, will take a JavaScript (or <abbr title="JavaScript Object Notation">JSON</abbr>) object and turn it into a <abbr title="Document Object Model">DOM</abbr> object that we can insert into our page. The second, `domToJs`, does the reverse. It takes a DOM object and converts it to <abbr title="JavaScript">JS</abbr>.

Now we can stringify it and persist it in our database. And rehydrate it at will.

As you may know, JSON does not recognize functions. So we will need a way to deal with our event listeners. Donʼt worry! Weʼve got it covered.

Check out our [example of DOM to JSON and back](https://sitebender.io/examples/dom-from-json/index.html) in action. We will explain it below.

> A key axiom of Craft Code is [less is more](https://craft-code.dev/axioms/less-is-more). This influences several of the Craft Code [methods](https://craft-code.dev/methods), such as [code just in time](https://craft-code.dev/methods/code-just-in-time) and [keep it simple](https://craft-code.dev/methods/keep-it-simple).
> 
> This means that we donʼt rush to load up dozens of frameworks, libraries, and other dependencies. Instead, we start with nothing: **zero dependencies.**
> 
> We build the site structure with semantically-correct and accessible <abbr title="HyperText Markup Language">HTML</abbr>. Then we add just enough <abbr title="Cascading Style Sheets">CSS</abbr> to make it attractive, user-friendly, and responsive.
> 
> Finally, we add JavaScript to **progressively enhance** the user experience. _But only if we need it._
> 
> This vanilla approach works very well. One goal of the Craft Code effort is to see how far we can go before we have to add a dependency on someone elseʼs code.

## The code

For the impatient, letʼs take a look at the final code. Then weʼll explain it. The next two functions are the totality of the module. The rest are specific to this example.

**Note: this is about concepts, not production-ready code. This is a first pass and might could use some refactoring.** <abbr title="Your Mileage May Vary">YMMV</abbr>.

```js
// ./modules/js-to-dom.js
export default async function jsToDom (js) {
  const { attributes, children, events, tagName } = js
  const elem = document.createElement(tagName)

  for (const attr in attributes) {
    elem.setAttribute(attr, attributes[attr])
  }

  if (Array.isArray(children)) {
    for (const child of children) {
      typeof child === "object"
        ? elem.appendChild(await jsToDom(child))
        : elem.appendChild(document.createTextNode(child))
    }
  }

  if (events) {
    for (const key in events) {
      if (!events[key]) {
        break
      }

      const handler =
        typeof events[key] === "function"
          ? events[key]
          : (await import(`./${events[key]}.js`)).default

      handler && elem.addEventListener(key, handler)
    }

    setDataEvents(elem, js.events)
  }

  return elem
}

function setDataEvents (elem, obj = {}) {
  const eventString = Object.keys(obj)
    .reduce((out, key) => {
      if (typeof obj[key] === "string") {
        out.push(`${key}:${obj[key]}`)
      }

      return out
    }, [])
    .join(",")

  if (eventString) {
    elem.setAttribute("data-events", eventString)
  }
}
```

We grab the `tagName` from the JSON and create a DOM element of that type. Then we add the attributes to that element. Then we apply `jsToDom` recursively on the `children`, appending them to the element.

The `events` object is pretty clever, <abbr title="In Our Humble Opinion">IOHO</abbr>. The keys are the names of the events (e.g., `click`) and the values are the names of the handler functions. We will import those functions only when needed. See an example below.

We also create a string representation of the events object. We could have simply stringified it, but we wanted to make it human readable, so we wrote our own (lines #39 to #52).

```js
// ./modules/dom-to-js.js
export default function domToJs (dom) {
  const { attributes, childNodes, tagName } = dom

  const eventList = dom.getAttribute("data-events")

  const events = eventList?.split(",").reduce((out, evt) => {
    const [key, value] = evt.split(":")

    if (key) {
      out[key] = value
    }

    return out
  }, {})

  const attrs = Object.values(attributes)
    .map((v) => v.localName)
    .filter((name) => name !== "data-events")

  return {
    tagName,
    attributes: attrs.reduce((out, attr) => {
      out[attr] = dom.getAttribute(attr)

      return out
    }, {}),
    events,
    children: Array.from(childNodes).map((_, idx) => {
      const child = childNodes[idx]

      return child.nodeType === Node.TEXT_NODE
        ? child.nodeValue
        : domToJs(child)
    }),
  }
}
```

This is a bit tricky as _nothing_ in the DOM is simple. Go figure. We explain below.

This extracts the `attributes`, the `childNodes`, and `tagName` from the passed DOM element. Then we use these to create a simple JS/JSON object, recursing through the child nodes.

We pull the `data-events` attribute out and treat it separately. We parse the value back into an actual object and add it at the `events` key.

The output is JS, but we can stringify it to JSON as required. The JSON shown below is a typical example. It creates our test form.

```json
{
  "tagName": "FORM",
  "attributes": {
    "action": "#",
    "method": "POST",
    "name": "form"
  },
  "events": {
    "focusin": "log",
    "submit": "parse-submission"
  },
  "children": [
    {
      "tagName": "TEXTAREA",
      "attributes": {
        "data-type": "json",
        "name": "json"
      },
      "children": [
        "{\"tagName\":\"DIV\",\"attributes\":{\"class\":\"sb-test\",\"data-type\":\"string\",\"id\":\"sb-test-id\"},\"events\":{\"click\":\"log\"},\"children\":[{\"tagName\":\"STRONG\",\"children\":[\"Bob's yer uncle.\"]}]}"
      ]
    },
    {
      "tagName": "BUTTON",
      "attributes": {
        "aria-label": "Run this baby",
        "type": "submit"
      },
      "children": [
        "Run"
      ]
    }
  ]
}
```

We import this JSON and pass it to `jsToDom` in our `index.js` file below.

```js
// index.js
import jsToDom from "./modules/js-to-dom.js"
import formJson from "./modules/form-json.js"
import outJson from "./modules/out-json.js"

export async function injectForm () {
  const main = document.querySelector("main")

  main.appendChild(
    await jsToDom(outJson),
  )

  main.appendChild(
    await jsToDom(formJson),
  )
}

globalThis.addEventListener("DOMContentLoaded", injectForm)
```

Pretty self-explanatory. Now, how do we handle our events?

Simple. We take our `events` object from the passed JSON/JS. Then we loop through the keys, which are the event types. The values are the **names** of the handler functions.

We add an event listener for each type and assign it the default function from the module with that name. For example, our output `div` gets a `click` handler called “log”. This function is in `./modules/log.js`.

We import the handler: `(await import("./log.js")).default`. We assign it to `handler`.

Then we add it like this: `addEventListener("click", handler)`.

Drop dead simple. And we only import the modules that we need. See the actual `log` handler below.

```js
// ./modules/log.js
export default function log ({ target }) {
  console.log(
    target?.tagName,
    target?.innerText || target?.value
  )
}
```

Kinda dumb, but it is merely an example. We add this `log` function as a `click` handler on our output `strong` element and as a `focusin` handler on our `form`.

The `submit` handler for the form is a bit more exciting:

```js
// ./modules/parse-submission.js
import domToJs from "./dom-to-js.js"
import jsToDom from "./js-to-dom.js"

export default async function (event) {
  event.preventDefault()

  const form = event.target
  const textarea = form.querySelector("textarea")
  const out = document.querySelector(".out")

  const js = JSON.parse(textarea.value)

  out.appendChild(await jsToDom(js))

  const newForm = domToJs(form)

  document.querySelector("main").appendChild(
    await jsToDom(newForm)
  )
}
```

We canʼt store functions in JSON, so we put them into modules. Then we will import them as needed when we rehydrate the DOM elements.

We attach `parseSubmission` as the `submit` handler for our `form` element.

## What can we do with this?

Ooo. All sorts of cool things.

### Easy element creation

Instead of messing around with `createElement`, `setAttribute`, etc., we can use `jsToDom`. We pass it a JS or JSON object representing the DOM elements we want.

We create handler functions ahead of time in modules. When we need an event listener, `jsToDom` imports it _just in time_ and assigns it to the element.

This works like Reactʼs `createElement` function. Or a library such as [hyperscript](https://github.com/hyperhype/hyperscript). Sure, weʼd prefer <abbr title="JavaScript XML">JSX</abbr> for its much reduced cognitive load. But our alternative here is the DOM methods such as `createElement`. Unless we want to load up a bulky library such as React, that is.

We donʼt.

Suppose I wanted to inject a password field with a show/hide button. First, we create a toggle handler such as this:

```js
// ./modules/toggle-visibility.js
export default function (event) {
  const button = event.target
  const div = button.closest(".form-field")
  const input = div?.querySelector("input")

  if (input) {
    if (input.type === "password") {
      input.type = "text"
      button.innerText = "hide"
      button.setAttribute("aria-label", "Hide password.")
      return
    }

    input.type = "password"
    button.innerText = "show"
    button.setAttribute("aria-label", "Show password.")
  }
}
```

Now I can call `jsToDom` with the following JSON and it will create my password input. Try pasting it into the [example form](http://sitebender.io/examples/dom-from-json/index.html). Remember that the click event handler is already available at `./modules/toggle-visibility.js`.

```json
{
  "tagName": "DIV",
  "attributes": {
    "class": "form-field"
  },
  "events": {
    "click": "log"
  },
  "children": [
    {
      "tagName": "INPUT",
      "attributes": {
        "type": "password"
      }
    },
    {
      "tagName": "BUTTON",
      "attributes": {
        "aria-label": "Show password.",
        "class": "xx-toggle-password",
        "type": "button"
      },
      "events": {
        "click": "toggle-visibility"
      },
      "children": ["show"]
    }
  ]
}
```

We hope that it is straightforward how all this works.

### Easy element persistance

What if I want to save current UI state? We can use `domToJs` to do just that.

We took the example page (linked above) and passed the `html` element to `domToJs`. Then we stringified it. Now we have preserved both the `head` and `body` elements.

So we can take a blank HTML document like this:

```html
<html lang="en">
  <head>
  </head>
  <body>
    <script src="./modules/make-page.js" type="module"></script>
  </body>
</html>
```

And we can use our persisted JSON `head` and `body` elements to create the page on the fly. We can store the JSON in a database or load it from an API.

Below is the code minus the JSON. Or view the [actual code.](http://sitebender.io/examples/dom-from-json/modules/make-page.js)

Then [see it in action.](https://sitebender.io/examples/dom-from-json/make-page.html) View the source on that page to see what we mean.

```js
import jsToDom from "./js-to-dom.js"
globalThis.addEventListener("DOMContentLoaded", async () => {
  const h = document.documentElement.querySelector("head")
  const b = document.documentElement.querySelector("body")

  h.replaceWith(await jsToDom(/* head JSON here */))

  b.replaceWith(await jsToDom(/* body JSON here */))
})
```

A whole page generated from simple JSON!

### Data-driven user interfaces

One idea that we have been promoting for several years now is that of a data-driven interface. Weʼve got an article in the pipeline on that coming soon. But we can give a quick overview here.

The idea is quite simple. The easiest example to visualize is automated form rendering.

**Forms collect data.** Typically, that data is then persisted in a database.

**Databases have schemas.** That means that the database already knows the types it expects. If we have defined our schema well, then it knows those types precisely.

**Particular data use particular interface widgets.** For example, an email address would use an `input` element of type “email”. An integer might use an input element of type “number” with its `step` attribute set to “1”.

An enum might use a `select` or `radio` buttons or a `checkbox` group.

From this schema we should be able to determine how to both **display and validate** that data. After all, there is only a small number of widgets.

So what if the response to our database query for the data included the schema? Some GraphQL queries already make this possible. From that schema, we can generate validation functions. _And_ we know which widgets to display.

**So we can generate our form and our validators automatically.**

Best of all, we have a single source of truth: our database schema.

In a coming article, we will explain how easy it is to achieve this.

(There is an advanced version of this that uses a [SHACL](https://en.wikipedia.org/wiki/SHACL) ontology and a [triple store](https://en.wikipedia.org/wiki/Triplestore) such as [Fuseki](https://jena.apache.org/documentation/fuseki2/). We then use [SPARQL](https://en.wikipedia.org/wiki/SPARQL) queries to generate the HTML, CSS, and JS straight from the database. Wee ha! Weʼll get to that sometime soon, too. Promise.)

If anyone requests it, weʼll give a detailed explanation of the above code in a separate article. This one is long enough.