Skip to content

How to implement your own client-side jsx runtime

Published: at 06:18 AM

I’ve shown how to provide your own JSX import source, now let’s focus on the client-side implementation.

Let’s start with types

As in the previous article, I took types from Svelte – which took theirs from React 18, etc. –, and for client-side I exported those types:

// types.d.ts

export interface HTMLElementsAttributes {
  a: HTMLAnchorAttributes;
  // …
}
export type TagName = keyof HTMLElementTagNameMap;

// That gives me easily the event handlers of the
// existing tags in their lowercase shape: `onclick`, `onfocus`, etc.
export type HTMLElementEventHandlers<Tag extends TagName> = Pick<
  HTMLElementTagNameMap[Tag],
  Extract<keyof HTMLElementTagNameMap[Tag], `on${string}`>
> &
  GlobalEventHandlers;

export type HTMLElements = {
  [Tag in keyof HTMLElementAttributes]: HTMLElementAttributes[Tag] &
    Partial<HTMLElementEventHandlers<Tag>>;
};

Cool. With that addition, we can get started on the implementation.

Static implementation

Let’s start with the easiest cases and forget about reactivity for now. I’ll write the functions as I discover them.

export function createElement<Tag extends TagName>(
  tag: Tag,
  props: HTMLElement[Tag],
  children: Children // string | number | …, only static stuff.
) {
  const element = document.createElement(tag);
  renderChildren(element, children);
  for (const [key, value] of Object.entries(props)) {
    // OPTIONAL: only if you added the ref prop.
    if (key === "ref") value(element);
    else if (isEventHandler(key, value)) setEventHandler(element, key, value);
    else setAttribute(element, key, value);
  }
}

function isEventHandler(key: string, value: unknown): value is AnyFunction {
  return isFunction(value) && key.startsWith("on");
}

function setEventHandler(
  element: HTMLElement,
  event: string,
  handler: (event: any) => any
) {
  const eventName = event.replace("on", "").toLowerCase();
  element.addEventListener(eventName, handler);
}

const setAttribute = (element: HTMLElement, key: string, value: unknown) => {
  // handle edge-cases first
  if (key === "checked") return (element[key] = value);
  if (key === "value") return (element[key] = value ?? "");
  if (typeof value === "boolean") element.toggleAttribute(key, value);
  else element.setAttribute(key, String(value));
};

function renderChildren(element: Element, children: Children) {
  children.flat().forEach(child => {
    renderChild(element, child);
  });
}

function renderChild(element: Element, child: Child) {
  const node = childToNode(child);
  // The node may have been added previously.
  // (applicable only when we’ll add reactivity)
  if (!element.contains(node)) element.append(node);
}

function childToNode(child: Child): Node {
  if (child instanceof Node) return child;
  if (child === undefined || child === null) return document.createTextNode("");
  if (typeof child === "boolean") return document.createTextNode("");
  if (typeof child === "number") return document.createTextNode(String(child));
  if (typeof child === "string") return document.createTextNode(child);

  const error = new Error("unhandled child type");
  Object.assign(error, { cause: child });
  throw error;
}

Boom. All good!

Add Reactivity

Until some reactive mechanism lands in the JS world – there’s a Signal proposal at the time of writing –, we’ll need to come up with our own implementation. Let’s bet on the platform and consider a probable API like:

type State<T> = { get: () => T, set: … }
type Computed<T> = { get: () => T }

To be able to render both State and Computed, the signature that interest us is { get: () => T }.

So let’s do it:

type Reactive<T> = { get: () => T }

// Update the children type
type Child = string | number | …
type Children = Array<Child | Reactive<Child>>

// Update the attributes type
type HTMLElements = {
  [Tag in keyof HTMLElementAttributes]: {
    [AttributeName in keyof HTMLElementAttributes[Tag]]: HTMLElementAttributes[Tag][AttributeName] | Reactive<HTMLElementAttributes[Tag][AttributeName]>
  } &
    Partial<HTMLElementEventHandlers<Tag>>;
}

export function createElement<Tag extends TagName>(
  tag: Tag,
  props: HTMLElement[Tag],
  children: Children
) {
  const element = document.createElement(tag);
  renderChildren(element, children);
  for (const [key, value] of Object.entries(props)) {
    // OPTIONAL: only if you added the ref prop.
    if (key === "ref") value(element);
    else if (isEventHandler(key, value)) setEventHandler(element, key, value);
    // NEW:
    else if (isReactive(value)) setReactiveAttribute(element, key, value)
    else setAttribute(element, key, value);
  }
}

function isReactive(value: any): value is Reactive<any> {
  return (
    !!value &&
    typeof value === 'object' &&
    typeof (value as any).get === 'function'
  );
}

function setReactiveAttribute<Tag extends TagName, AttributeName extends string>(
  element: Element,
  key: AttributeName,
  value: Reactive<HTMLElements[Tag][AttributeName]>
) {
  // TODO: avoid running effect when the element
  // is disconnected from the dom.

  // the proposal is shipped with an `effect` function, let’s use it:
  effect(() => {
    // leverage the existing `setAttribute` implementation` !
    setAttribute(element, key, value.get())
  })
}

function renderChildren(element: Element, children: Children) {
  children.flat().forEach((child) => {
    isReactive(child)
      ? renderReactiveChild(element, child)
      : renderChild(element, child)
  })
}

function renderReactiveChild(element: Element, signal: Reactive<Child>) {
  // TODO: avoid running effect when the element
  // is disconnected from the dom.

  // To spare us some work, we’ll consider one use-case, and one use-case only: when we have a child list.
  // Why do I do that ? Because a single child can be considered as a list of 1 element.
  // Plus, a reactive child can see its value change from a single child to multiple children. Handling a single child as a child list covers all scenarios.

  // keep a previousNode reference to handle updates and removal of child lists.
  let previousNode: Node[];
  effect(() => {
    const child = signal.get()
    const children = Array.isArray(child) ? child : [child]
    const nodes = asList.map(childToNode)

    // "anchor" because the node I’ll take as reference to know where
    // to append/replace old nodes by new nodes.
    const anchor = previousNodes[0];
    // handle edge-case first: first render
    if (!anchor) element.append(...nodes)
    else {
      // because I’ll be removing old nodes, I need to persist an anchor first.
      const tempAnchor = document.createTextNode();
      // insert temp anchor before the anchor –previous– node.
      element.insertBefore(tempAnchor, anchor)
      previousNodes.forEach((node) => element.removeChild(node))
      // add all new nodes before temp anchor
      nodes.forEach((node) => element.insertBefore(node, tempAnchor))

      element.removeChild(tempAnchor)
    }
    previousNode = nodes; // keep reference !
  })
}

The good ol’ <Counter /> example

interface Props {
  initialCount?: number;
}
function Counter({ initialCount = 0 }: Props) {
  const count = new Signal.State(initialCount)
  const decrement = (event: Event) => count.update((n) => n - 1)
  const increment = (event: Event) => count.update((n) => n + 1)

  return (
    <div class="counter" data-count={count}>
      <button type="button" onclick={decrement}>
        -
      </button>
      <span>{count}</span>
      <button type="button" onclick={increment}>
        +
      </button>
    </span>
  )
}

There you go!

Obviously, you may encounter some edge-cases, I personally use my implementation for my side-projects, but you know, at my own risk.

So far so good, I enjoyed the journey and hope you did too. Enjoy your handcrafted < 2kb jsx runtime 🤗

PS: Here’s the full version of my home-made jam implementation, all in a CodeSandbox ❤️.