Write your own DOM element factory for TypeScript

 
 
  • Gérald Barré

The DOM API allows you to manipulate HTML documents in a browser. It is straightforward to use, but the resulting code is not very readable. Here is the code needed to create two elements and set an attribute:

JavaScript
function foo() {
    let div = document.createElement("div");
    let anchor = document.createElement("a");
    anchor.href = "https://www.meziantou.net";
    div.appendChild(anchor);
    return div;
}

The JSX syntax introduced by React lets you mix JavaScript and HTML-like markup. The goal is to create code that is easy to write and read. JSX is compiled by a preprocessor such as Babel (or TypeScript, as we will see later) to valid JavaScript. Using JSX, the previous function can be written as:

TypeScript
function foo() {
    return (
        <div>
            <a href="https://www.meziantou.net">meziantou</a>
        </div>
    );
}

If you use React, the code will be converted to:

TypeScript
function foo() {
    return React.createElement("div", { "class": className },
        React.createElement("a", { href: "https://www.meziantou.net" }, "meziantou"));
}

React.createElement creates a virtual DOM that is eventually translated to real DOM elements. The virtual DOM reduces the number of DOM operations, but that detail is not important for this post. For simplicity, think of React.createElement as equivalent to document.createElement.

TypeScript supports the JSX syntax, so you can take advantage of the type checker, IDE autocompletion, and refactoring. TypeScript can preserve the JSX syntax (if you want to use another preprocessor), replace it with React.createElement, or use a custom factory. The last option lets you use JSX without React, as long as you provide your own factory.

A factory is just a function with the following declaration:

TypeScript
 interface AttributeCollection {
    [name: string]: string | boolean;
}

var Fragment;

function createElement(tagName: string, attributes: AttributeCollection | null, ...children: any[]): any

Implementing this function is straightforward: use document.createElement, set the attributes, and append the children. There are a few things worth noting, however.

  • JSX does not allow the attribute class. Instead, you have to use className. So, the factory has to handle this case.
  • Using JSX, you can register an event handler using <a onclick={...}></a>. However, the setAttribute function only accepts a string value. So, you have to handle this case by using addEventListener.
  • Fragments (<>...</>) are replaced by factory.createElement(factory.Fragment, null, ...). So, we can use a special name to create a DocumentFragment in the createElement function.
TypeScript
namespace MyFactory {
    const Fragment = "<></>";

    export function createElement(tagName: string, attributes: JSX.AttributeCollection | null, ...children: any[]): Element | DocumentFragment {
        if (tagName === Fragment) {
            return document.createDocumentFragment();
        }

        const element = document.createElement(tagName);
        if (attributes) {
            for (const key of Object.keys(attributes)) {
                const attributeValue = attributes[key];

                if (key === "className") { // JSX does not allow class as a valid name
                    element.setAttribute("class", attributeValue);
                } else if (key.startsWith("on") && typeof attributes[key] === "function") {
                    element.addEventListener(key.substring(2), attributeValue);
                } else {
                    // <input disable />      { disable: true }
                    // <input type="text" />  { type: "text"}
                    if (typeof attributeValue === "boolean" && attributeValue) {
                        element.setAttribute(key, "");
                    } else {
                        element.setAttribute(key, attributeValue);
                    }
                }
            }
        }

        for (const child of children) {
            appendChild(element, child);
        }

        return element;
    }

    function appendChild(parent: Node, child: any) {
        if (typeof child === "undefined" || child === null) {
            return;
        }

        if (Array.isArray(child)) {
            for (const value of child) {
                appendChild(parent, value);
            }
        } else if (typeof child === "string") {
            parent.appendChild(document.createTextNode(child));
        } else if (child instanceof Node) {
            parent.appendChild(child);
        } else if (typeof child === "boolean") {
            // <>{condition && <a>Display when condition is true</a>}</>
            // if condition is false, the child is a boolean, but we don't want to display anything
        } else {
            parent.appendChild(document.createTextNode(String(child)));
        }
    }
}

Finally, you need to update tsconfig.json to tell the TypeScript compiler how to handle JSX:

JSON
{
    "compilerOptions": {
        "jsx": "react", // use the React mode, so call the factory
        "jsxFactory": "MyFactory.createElement" // The name of the factory function
    }
}

You can now create a file with the .tsx extension and use the JSX syntax. I hope this helps you write cleaner DOM code!

If you want to see a real usage of a custom factory, you can check my Password Manager project on GitHub.

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?