Skip to contentVisit my new website at chrisnicholas.dev
Article posted on

A Reactive Framework in 40 Lines
How to create a JavaScript framework like React, or Vue, using proxy & custom elements.

So how exactly do React & Vue work? It can be invaluable to understand the internals of a system, which is why in this article I'll be explaining one method to create a basic reactive framework (in just 40 lines of code).
Table of contents

So how exactly do React & Vue work? It can be invaluable to understand the internals of a system, which is why in this article I'll be explaining one method to create a basic reactive framework (in just 40 lines of code).

What are we making?

We'll be making a JavaScript framework with reactive properties, slots, attributes, and custom elements. Here's an example of a working component using our new framework:

<click-counter start="50">Try me:</click-counter>

The component definition for click-counter will look like this (don't worry if it doesn't make total sense yet!):

Component definition
export const name = 'click-counter'

export function setup ({ start }) {
  return {
    count: parseInt(start)
  }
}

export function render () {
  return `
    ${this._slot}
    <button id="minus"> - </button>
    ${this.count}
    <button id="plus"> + </button>
  `
}

export function run () {
  this._find('#plus').onclick = () => this.count++
  this._find('#minus').onclick = () => this.count--
}

Right, let's get started!

Native custom elements

React and Vue allow you to make use of components with custom tags such as <CustomComponent />, and then these are compiled into regular HTML elements such as <div>. In this framework we'll make use of a simpler and tidier approach—custom elements.

Defining elements

Autonomous custom elements allow you to use customised tags natively in HTML, after a short class definition in JavaScript. Simply extend HTMLElement then call customElement.define():

class CustomComponent extends HTMLElement {
  // Optional hook
  connectedCallback () {
    this.innerHTML = "I'm valid!"
  }
}
customElements.define('custom-component', CustomComponent)

Voilà, custom-component is now a valid custom HTML element and can hold any kind of custom attribute! I've added an optional connectedCallback hook here, and this runs when an instance of the element is added to the DOM:

<custom-component></custom-component>
I'm valid!

Starting out

Our framework will be contained within a single createComponent function that defines a custom element:

createComponent.js
export default function createComponent (Component) {
  class ReactiveElement extends HTMLElement {
    connectedCallback () {
    // We'll put our code here
    ...
  }
}

  // Define custom element
  customElements.define(Component.name, ReactiveElement)
}

An object will be passed to the function with a name property that will be used to define the custom element's custom name.

Other files

To test our framework we'll also have another file where we define the current component, for now we'll just give it a name:

ClickCounter.js
export const name = 'click-counter'

And a final file where we create the component and add it to the page:

index.js
import createComponent from './createComponent.js'
import * as ClickCounter from './CustomElement.js'

createComponent(ClickCounter)

document.body.innerHTML = `
  <click-counter>I am a custom element</click-counter>
`

You can follow along and build the framework yourself in CodeSandbox:

Edit on CodeSandbox
Reactive framework in 40 lines, initial template

Defining state & props

Each of our components will have a state, a series of (soon to be reactive) properties that can be manipulated for use within individual component instances. For example, in our click-counter element, the state will hold the current click count.

Props are attributes

We'll also be making use of props, which are essentially just HTML attributes passed to the object. We can retrieve HTML attributes in this way:

<custom-element title="Hello" text="I am a prop"></custom-element>
const element = document.querySelector('custom-element'}

const props = {}
Array.from(element.attributes).forEach(
  attr => props[attr.nodeName] = attr.nodeValue
)

// { title: 'Hello', text: 'I am a prop' }
console.log(props)

Setup hook

We'll use state within our components by exporting a setup method, and attaching our variable to this. Props will be made available in the setup argument:

<click-counter start="50"></click-counter>
ClickCounter.js
export const name = 'click-counter'

// Setup called once on creation
export function setup ({ start }) {
  this.count = parseInt(start)
}

// Use state later
...

Assigned a property to this will be roughly equivalent to setting a property in Vue data, or creating a React useState() variable.

Implementing setup

To implement this in createComponent we'll first get the element's props, then create an empty state object, before calling the setup hook.

Inside connectedCallback, createComponent.js
// Get element's props
const props = {}
Array.from(this.attributes).forEach(
  attr => (props[attr.nodeName] = attr.nodeValue)
)

// State object
let state = {}

// Call setup method
Component.setup.call(state, props)

Component.set.call(state, props) is used here instead of simply Component.setup(props) because it allows us to set state as the context for this.

Helper methods

We can also add a few helper methods, to make accessing the element (and it's children) easier. We'll create state using Object.create to pass some non-enumerable methods to its prototype:

Replacing state instantiation, createComponent.js
// State object
let state = Object.create({
  _elem: this,
  _find: sel => this.querySelector(sel),
  _slot: this.innerHTML
})
Helper method table
MethodUseExample
_elemReturn the current HTMLElementthis._elem.style.color = 'red'
_findShorthand for selecting child elementsconst div = this._find('div')
_slotGet initial slot/childrenthis.text = 'Hi ' + this._slot

We'll be making use of these after we've added some reactivity.

Putting it together

If we put it all together, our function is currently looking like this:

createComponent.js
export default function CreateComponent (Component) {
  class ReactiveElement extends HTMLElement {
    connectedCallback () {
      // Get element's props
      const props = {}
      Array.from(this.attributes).forEach(
        attr => (props[attr.nodeName] = attr.nodeValue)
      )

      // State object, with helper methods
      let state = Object.create({
        _elem: this,
        _find: sel => this.querySelector(sel),
        _slot: this.innerHTML
      })

      // Call setup method
      Component.setup.call(state, props)
    }
  }

  // Define custom element
  customElements.define(Component.name, ReactiveElement)
}

Rendering to the DOM

To render our component to the page, we'll make a render hook available:

ClickCounter.js
export const name = 'click-counter'

export function setup ({ start }) {
  this.count = parseInt(start)
}

// Render return value to page
export function render () {
  return `
    Current count: ${this.count}
  `
}
<click-counter start="50"></click-counter>
Current count: 50

For this, we'll use a simple function that simply places the result into the innerHTML of the element, though we'll come to that in a minute.

Virtual DOM

React and Vue both use virtual DOMs, which are essentially abstractions of the DOM within JavaScript. Building a virtual DOM would more than double the length of this guide, so we're skipping it for today! We're replacing the entire innerHTML instead, which will reset the internal DOM on every render.

Post-render hook

Because there's no virtual DOM, and the DOM won't keep it's state, we need a way to affect the body (e.g., place event listeners) after rendering. To do this, we'll enable a final hook, the run hook.

ClickCounter.js
export const name = 'click-counter'

export function setup ({ start }) {
  this.count = parseInt(start)
}

export function render () {
  return `
    Current count: ${this.count}
  `
}

// Run after each render
export function run () {
  // Add event listeners, etc
  ...
}

Render function

The render function is very basic, it simply updates the component innerHTML to the return value of render, calls run and finishes. Again, it uses call() to pass state as the context:

Inside connectedCallback, createComponent.js
// Render to DOM
const renderElement = () => {
  this.innerHTML = Component.render.call(state, props)
  Component.run.call(state, props)
}

The code so far

Here's the entire function so far. Note that we're also calling the new render method at the end of connectedCallback, to display the initial render:

Inside connectedCallback, createComponent.js
export default function CreateComponent (Component) {
  class ReactiveElement extends HTMLElement {
    connectedCallback () {
      // Get element's props
      const props = {}
      Array.from(this.attributes).forEach(
        attr => (props[attr.nodeName] = attr.nodeValue)
      )

      // State object, with helper methods
      let state = Object.create({
        _elem: this,
        _find: sel => this.querySelector(sel),
        _slot: this.innerHTML
      })

      // Render to DOM
      const renderElement = () => {
        this.innerHTML = Component.render.call(state, props)
        Component.run.call(state, props)
      }

      // Run component
      Component.setup.call(state, props)
      renderElement()
    }
  }

  // Define custom element
  customElements.define(Component.name, ReactiveElement)
}

Adding reactivity

The final step in our framework is, of course, to add reactivity. There are a few different ways to do this, React uses useState, an object that creates a setter function, though I prefer Vue's (in my opinion!) more elegant method; Proxy.

JavaScript Proxy

JavaScript Proxies work by attaching a handler to a target. Certain handler functions are called at various times in the proxy's lifecycle, for example set is called when a proxy's property changes.

// The target of the proxy
const target = {
  fruit: 'apple'
}

// Functions called by proxy, `set` is used to monitor property changes
const handler = {
  // When a property is changed on `target`, this function is called
  set: (obj, prop, value) => {
    console.log(`${prop} changed to ${value}`)
  }
}

// Create proxy
const proxy = new Proxy(target, handler)

// 'fruit changed to banana'
proxy.fruit = 'banana'

Following on from the example above, if you haven't used proxies before, you may be surprised to see this:

// 'apple'
console.log(proxy.fruit)

But didn't we change that to 'banana'? Not quite. Handler functions override the default behaviour; 'banana' was never actually set, and this must be accounted for:

set: (obj, prop, value) => {
  console.log(`${prop} changed to ${value}`)
  
  // Run default set behaviour, and return
  return Reflect.set(obj, prop, value)
}

The Reflect object provides a series of functions that emulate the default behaviour of certain parts of JavaScript. Reflect.set() emulates setting a property, and this is then returned to provide the default functionality.

Reactive state

We can add reactivity to our framework by creating a similar proxy method for our state object, which then calls the render function and updates the component's DOM. Note that we're running renderElement() after setting the object; the body shouldn't update until after the new property value updates.

After state helper method instantiation, createComponent.js
// State instantiated above

// Add proxy to state and watch for changes
state = new Proxy(state, {
  set: (obj, prop, value) => {
    const result = Reflect.set(obj, prop, value)
    renderElement()
    return result
  }
})

We can now test this out using a helper method from before:

ClickCounter.js
export const name = 'click-counter'

export function setup ({ start }) {
  this.count = parseInt(start)
}

export function render () {
  return `
    Current count: ${this.count}
  `
}

export function run () {
  // Testing reactive `count`
  this._elem.onclick = () => this.count++
}

_elem returns the current HTMLElement object, and here we attach a simple event method to it.

<click-counter start="50"></click-counter>

Clicking on the element will now increment count and reactively update the body!

Preventing loops

Currently, an infinite loop could occur quite easily within the run hook, if the state updates synchronously (don't try this!):

Inside ClickCounter.js
// Runs after body update
export function run () {
  // Forces body to update
  this.count++
}

We need to modify our render method to prevent this occurring, and only allow asynchronous reactivity. Adding a quick if statement looks a little untidy, but will do the job:

Replacement for renderElement(), createComponent.js
// Render to DOM
let rendering = false
const renderElement = () => {
  if (rendering === false) {
    rendering = true
    this.innerHTML = Component.render.call(state, props)
    Component.run.call(state, props)
    rendering = false
  }
}

The problem has now been fixed:

Inside ClickCounter.js
export function run () {
  // Synchronous, won't re-render
  this.count++
  
  // Asynchronous, will re-render
  setTimeout(() => this.count++, 1000)
  this._elem.onclick = () => this.count++
}

The final code

If we make our modifications to createComponent one last time, it looks like this:

createComponent.js
export default function CreateComponent (Component) {
  class ReactiveElement extends HTMLElement {
    connectedCallback () {
      // Get element's props
      const props = {}
      Array.from(this.attributes).forEach(
        attr => (props[attr.nodeName] = attr.nodeValue)
      )

      // Attach helper methods to state
      let state = Object.create({
        _elem: this,
        _find: sel => this.querySelector(sel),
        _slot: this.innerHTML
      })

      // Add proxy to state and watch for changes
      state = new Proxy(state, {
        set: (obj, prop, value) => {
          const result = Reflect.set(obj, prop, value)
          renderElement()
          return result
        }
      })

      // Render to body method
      let rendering = false
      const renderElement = () => {
        if (rendering === false) {
          rendering = true
          this.innerHTML = Component.render.call(state, props)
          Component.run.call(state, props)
          rendering = false
        }
      }

      // Run component
      Component.setup.call(state, props)
      renderElement()
    }
  }

  // Define custom element
  customElements.define(Component.name, ReactiveElement)
}

You can find the working code on CodeSandbox, along with a few examples:

Edit on CodeSandbox
Reactive framework in 40 lines, complete with examples

Summary

Ta-da! A reactive JavaScript framework in just 40 lines of code (34, to be precise). I hope you've enjoyed reading, you can reach out to me on Twitter if you'd like to say hi, and I'll leave you with a few more component examples.

A few components

<live-clock>The time is</live-clock>
Definition
// Component name
export const name = 'live-clock'

const getDate = () => new Date().toLocaleTimeString()

// Runs once on creation
export function setup () {
  this.time = getDate()
}

// Runs on body update
export function render () {
  return `${this._slot} <strong>${this.time}</strong>`
}

// Runs after body update
export function run () {
  setTimeout(() => this.time = getDate(), 1000)
}
<simple-counter></simple-counter>
Definition
// Component name
export const name = 'simple-counter'

// Runs once on creation
export function setup() {
  this.count = 0
}

// Runs on body update
export function render() {
  return `<button>${this.count || this._slot}</button>`
}

// Runs after body update
export function run() {
  this._find('button').onclick = () => this.count++
}
<reactive-counter start="50"></reactive-counter>
Definition
export const name = 'click-counter'

export function setup ({ start }) {
  this.count = parseInt(start)
}

export function render () {
  return `
    ${this._slot}
    <button id="minus"> - </button>
    ${this.count}
    <button id="plus"> + </button>
  `
}s

export function run () {
  this._find('#plus').onclick = () => this.count++
  this._find('#minus').onclick = () => this.count--
}
<country-flag country="ca"></country-flag>
<country-flag country="mk"></country-flag>
<country-flag country="sc"></country-flag>
Definition
// Component name
export const name = 'country-flag'

async function getCountry (code) {
  return fetch('https://restcountries.eu/rest/v2/alpha/' + code).then(
    response => response.json()
  )
}

// Runs once on creation
export function setup ({ country = 'gb' }) {
  getCountry(country).then(({ flag }) => this.flagUrl = flag)
  this.count = 0
  this.flagUrl = ''
}

// Runs on body update
export function render () {
  const flag = this.flagUrl
    ? `<img src="${this.flagUrl}" />`
    : 'no flag'
  return `
    <div>${flag}</div>
  `
}

// Runs after body update
export function run () {}