Building a Frontend Framework; Reactivity and Composability With Zero Dependencies

Before I start—to set some context—by frontend framework what I mean is, a framework that allows us to avoid having to write regular old HTML and JavaScript such as this:

<p id="cool-para"></p>
<script>
  const coolPara = 'Lorem ipsum.';
  const el = document.getElementById('cool-para');
  el.innerText = coolPara;
</script>

and instead allows us to write magical HTML and JavaScript code such as this (Vue):

<script setup>
  const coolPara = 'Lorem ipsum.';
</script>
<template>
  <p>{{ coolPara }}</p>
</template>

or this (React):

export default function Para() {
  const coolPara = 'Lorem ipsum';
  return <p>{ coolPara }</p>;
}

and the benefit of such a framework is understandable. Remembering words or phrases such as document, innerText, and getElementById are difficult—so many syllables!

Okay, syllable count isn’t the main reason.

Reactivity ✨

The first main reason is that, in the second and third examples, we can just set or update the value of the variable coolPara and the markup—i.e. the <p> element—is updated without without explicitly having to set its innerText.

This is called reactivity, the UI is tied to the data in such a way that just changing the data updates the UI.

Composability ✨

The second main reason is the ability to define a component and reuse it without having to redefine it every time we need to use it. This is called composability.

Regular HTML + JavaScript does not have this by default. And so the following code does not do what it feels like it should:

<!-- Defining the component -->
<component name="cool-para">
  <p>
    <content />
  </p>
</component>

<!-- Using the component -->
<cool-para>Lorem ipsum.</cool-para>

Reactivity and composability are the two main things the usual frontend frameworks such as Vue, React, etc give us.

These abstractions aren’t granted for free, one has to front-load a bunch of framework specific concepts, deal with their leakiness when things work in inexplicably magical ways, and not to mention, a whole load of failure-prone dependencies.

But, it turns out that using modern Web APIs these two things aren’t very hard to achieve. And most use cases we might not actually need the usual frameworks and their cacophony of complexities…

Reactivity

A simple statement that explains reactivity is when the data updates, update the UI automatically.

The first part is to know when the data updates. This unfortunately is not something a regular object can do. We can't just attach a listener called ondataupdate to listen to data update events.

Fortunately JavaScript has just the thing that would allow us to do this, it’s called Proxy.

Proxy Objects

Proxy allows us to create a proxy object from a regular object:

const user = { name: 'Lin' };
const proxy = new Proxy(user, {});

and this proxy object can then listen to changes to the data.

In the example above we have a proxy object, but it is not really doing anything when it comes to know that name has changed.

For that we need a handler, which is an object that tells the proxy object what to do when the data is updated.

// Handler that listens to data assignment operations
const handler = {
  set(user, value, property) {
    console.log(`${property} is being updated`);
    return Reflect.set(user, value, property);
  },
};

// Creating a proxy with the handler
const user = { name: 'Lin' };
const proxy = new Proxy(user, handler);

Now whenever we update name using the proxy object, we’ll get a message saying "name is being updated".

If you’re wondering, What’s the big deal, I could’ve done this using a regular old setter, I’ll tell you the deal:

Other than this you can handle several other access events such as when a property is read, updated, deleted, etc.

Now that we have the ability to listen to listen to operations, we need to react to them in a meaningful way.

Updating the UI

If you recall, The second part of reactivity was update the UI automatically. For this we need to fetch the appropriate UI element to be updated. But before that that we need to first mark a UI element as appropriate.

To do this we’ll use data-attributes, a feature that allows us to set arbitrary values on an element:

<div>
  <!-- Mark the h1 as appropriate for when "name" changes -->
  <h1 data-mark="name"></h1>
</div>

The nicety of data-attributes are that we can now find all the appropriate elements using:

document.querySelectorAll('[data-mark="name"]');

Now we just set the innerText of all the appropriate elements:

const handler = {
  set(user, value, property) {
    const query = `[data-mark="${property}"]`;
    const elements = document.querySelectorAll(query);

    for (const el of elements) {
      el.innerText = value;
    }

    return Reflect.set(user, value, property);
  },
};

// Regular object is omitted cause it's not needed.
const user = new Proxy({ name: 'Lin' }, handler);

That’s it, that’s the crux of reactivity!

Because of the general nature of our handler, for any property of user that is set, all the appropriate UI elements will be updated.

That’s how powerful the JavaScript Proxy features are, with zero dependencies and some cleverness it can give us these magical reactive objects.

Now onto the second main thing…

Composibility

Turns out, browsers already have an entire feature dedicated to this called Web Components, who knew!

Few use it cause it’s a bit of a pain in the ass to use (and also because most reach out for the usual frameworks as a default when starting a project, irrespective of the scope).

For composability we first need to define the components.

Defining components using template and slot

The <template> tags are used to contain markup which is not rendered by the browser. For instance, you can add the following markup in your HTML:

<template>
  <h1>Will not render!</h1>
</template>

and it won’t be rendered. You can think of them as invisible containers for your components.

The next building block is the <slot> element which defines where the content of a component will be placed in it. This enables a component to be reused with different content, i.e it becomes composable.

For example, here’s an h1 element that colors its text red.

<template>
  <h1 style="color: red">
    <slot />
  </h1>
</template>

Before we get to using our components—like the red h1 above, we need to register them.

Registering the Components

Before we can register our red h1 component, we need a name to register it by. We can just use the name attribute for that:

<template name="red-h1">
  <h1 style="color: red">
    <slot />
  </h1>
</template>

And now, using some JavaScript we can get the component and its name:

const template = document.getElementsByTagName('template')[0];
const componentName = template.getAttribute('name');

and then finally register it using customElements.define:

customElements.define(
  componentName,
  class extends HTMLElement {
    constructor() {
      super();
      const component = template.content.children[0].cloneNode(true);
      this.attachShadow({ mode: 'open' }).appendChild(component);
    }
  }
);

There is a lot going on in the block above:

What we are doing in the class constructor is using a copy of the template red-h1 to set the shadow DOM tree.

What’s the Shadow DOM?

The shadow DOM is what sets the styling of a several default elements such as a range input, or a video element.

The shadow DOM of an element is hidden by default which is why we can’t see it in the dev console, but here’re we’re setting the mode to 'open'.

This allows us to inspect element and see that the red colored h1 is attached to the #shadow-root.

Calling customElements.define will allow us to use the defined component like a regular HTML element.

<red-h1>This will render in red!</red-h1>

Onto putting these two concepts together!

Composability + Reactivity

A quick recap, we did two things:

  1. We created a reactive data structure i.e. the proxy objects which on setting a value can update any element we have marked as appropriate.
  2. We defined a custom component red-h1 which will render it’s content as a red h1.

We can now put them both together:

<div>
  <red-h1 data-mark="name"></red-h1>
</div>

<script>
  const user = new Proxy({}, handler);
  user.name = 'Lin';
</script>

and have a custom component render our data and update the UI when we change the data.


Of course the usual frontend frameworks don’t just do this, they have specialized syntax such the template syntax in Vue, and JSX in React that makes writing complex frontends relatively more concise that it otherwise would be.

Since this specialized syntax is not regular JavaScript or HTML, it is not parsable by a browser and so they all need specialized tools to compile them down to regular JavaScript, HTML, and CSS before the browser can understand them. And so, no body writes JavaScript any more.

Even without specialized syntax, you can do a lot of what the usual frontend framework does—with similar conciseness—just by using Proxy and WebComponents.

The code here is an over simplification and to convert it into a framework you’d have to flesh it out. Here’s my attempt at doing just that: a framework called Strawberry.

As I develop this, I plan on maintaining two hard constraints:

  1. No dependencies.
  2. No build-step before it can be used.

And a soft constraint of keeping the code base tiny. At the time of writing it’s just a single file with fewer than 400 CLOC, let’s see where it goes. ✌️

Also, here's the HN discussion for this post.