
JavaScript Views, the Hard Way – A Pattern for Writing UI by voat
Learn how to build views in plain JavaScript in a way that is maintainable, performant, and fun. Writing JavaScript Views the Hard Way is inspired by such books as Learn C the Hard Way.
Writing JavaScript Views the Hard Way is a pattern for writing JavaScript views. It is meant to serve as an alternative to using frameworks and libraries such as React, Vue and lit-html.
It is a pattern, not a library. This document explains how to write views in such a way as to avoid the spaghetti code problems that commonly occur when writing low-level imperative code.
We call this technique the hard way because it eschews abstractions in favor of directness.
There are several reasons why you might be interested in writing your views the hard way:
- Performance: Writing JavaScript Views the Hard Way uses direct imperative code, so there are no unnecessary operations performed. Whether a hot or cold path, using this technique ensures nearly the best possible performance you can get in JavaScript.
- 0 dependencies: This technique uses no dependencies, so your code will never have to be upgraded. Have you ever used a library that released a breaking change that took you a day to upgrade? You’ll never experience that problem again.
- Portability: Code written with simple imperative views is portable to any framework. That makes it perfect for low-level components that you might want to share with several framework communities. But I recommend using it on full apps as well.
- Maintainability: Despite the reputation of imperative code being difficult to maintain, views written with Writing JavaScript Views the Hard Way are extremely maintainable. This is because they follow strict conventions (you’ll learn these later). These conventions ensure you always know where to look in a view. Additionally it follows a props down, events up model that makes data sharing straight-forward.
- Browser support: Code written in this manner is supported by all browsers; full-stop. We do use events to make passing data back up the component tree and our examples use a newer, nicer API, to do that, but you can use an older technique (discussed in the compatibility section) to get you back to at least IE9. But if you want to go further back than that even, substitute passing functions as props instead of using events and you can use this technique in IE6 if you want. And it will be by far the most performant solution you’ll find for old browsers.
- Easier to debug: Using this approach stack traces become shallow (usually only a few function calls). This is because there are no layers between events and your code. Everything is your code, and as long as you name your functions, you’ll get incredible stack traces that make it easy to trace where something goes wrong.
- Functional: This doesn’t differentiate the technique vs all frameworks but it’s worth pointing out at a benefit. Writing JavaScript Views the Hard Way is not functional in the immutable sense; there are definitely mutations; but it is functional in the sense that you’re dealing with plain functions (no classes in sight) and without side-effects outside of the view’s local state.
Enough with the arguments for now, let’s talk about the structure. A view component written with Writing JavaScript Views the Hard Way looks like the following. This is a full hello world. From here we’ll break down each part and explain it on its own.
Once you understand each part you’ll know how to build components/views using this pattern; it’s everything you need to know.
world!
`;
function clone() {
return document.importNode(template.content, true);
}
function init() {
/* DOM variables */
let frag = clone();
let nameNode = frag.querySelector(‘#name’);
/* State variables */
let name;
/* DOM update functions */
function setNameNode(value) {
nameNode.textContent = value;
}
/* State update functions */
function setName(value) {
if(name !== value) {
name = value;
setNameNode(value);
}
}
/* State logic */
/* Event dispatchers */
/* Event listeners */
/* Initialization */
function update(data = {}) {
if(data.name) setName(data.name);
return frag;
}
return update;
}
export default init;” dir=”auto”>
const template = document.createElement('template'); template.innerHTML = `Hello world!`; function clone() { return document.importNode(template.content, true); } function init() { /* DOM variables */ let frag = clone(); let nameNode = frag.querySelector('#name'); /* State variables */ let name; /* DOM update functions */ function setNameNode(value) { nameNode.textContent = value; } /* State update functions */ function setName(value) { if(name !== value) { name = value; setNameNode(value); } } /* State logic */ /* Event dispatchers */ /* Event listeners */ /* Initialization */ function update(data = {}) { if(data.name) setName(data.name); return frag; } return update; } export default init;
This is the basic structure. More details are to follow. First let’s concentrate on the module’s parts and exports.
world!
`;” dir=”auto”>
const template = document.createElement('template'); template.innerHTML = `Hello world!`;
This is the view’s template. It’s a element. Setting its innerHTML
causes the browser to parse and save this HTML as the template’s template.content
property. This is a DocumentFragment that can be quickly cloned.
Notice that there are no interpolations with data. This is because, as of now, the browser doesn’t support any such API. In the spirit of The Hard Way we use only what the browser gives us.
So instead of interpolating, we add elements at points within the HTML that we will want to update later. In this example we have world
. This gives us something that we can query and update later (via frag.querySelector('#name')
for example).
Note: ids are global to the document. If the component you are working on is likely to be used multiple times in an application (such as a list item) you should probably not use ids, but rather a class name or possibly a data attribute.
function clone() { return document.importNode(template.content, true); }
This function is mostly for convenience. All it does is clone the template and return the result.
However there are cases where you might want to slightly adjust the output. document.importNode returns a fragment; there are cases where you want the returned node to be an element (mostly so that the consumer of your view can set up event listeners). So to return the root element you can change clone to:
function clone() { return document.importNode(template.content, true).firstElementChild; }
This is the function that gets called by parent views in order to create a new view instance. Going with our hello world example, a consumer that wants to insert this into the page would do:
” dir=”auto”>
> <html lang="en"> <title>Hello worldtitle> <main>main> <script type="module"> import init from './view.js'; const main = document.querySelector('main'); const update = init(); main.appendChild(update({ name: 'world' })); script>