Yet Another Web Framework

Build your own JavaScript signals and framework, part 2.

About 3 months ago, I published an article titled Let’s Make A Web Framework. While it was a fun article to write, I’ve been working on another reactivity system for JavaScript that I think is better, and I want to share it with you. Let’s dive in!

Prerequisites

Before you read this article, you should be comfortable with some elements of JavaScript. If you don’t know any JavaScript, this article may not be good for you. You should also catch up on ES6 constructs, as we’ll use them frequently. Specifically, we’ll use the following concepts in our code:

  • destructuring
  • arrow functions
  • let and const variables
  • passing functions as parameters
  • Sets
  • instanceof
  • typeof
  • == null to check for null and undefined
  • spread and rest syntax
  • shorthand object methods

If you don’t know all of these, don’t worry. Just Google them and you’ll get the hang of the concepts behind them pretty quickly. Now, let’s get on to the actual article!

The reactive primitives

The system described in the article above had two reactive primitives: effects and signals. However, I noticed that signals store values, which doesn’t really make them actual primitives, as the value behavior could be implemented from a smaller construct. Instead, this article will use events instead of signals. While they have the same name as DOM events, our events will have a different, simpler API.

When an event is created, it will return a track function and a trigger function. When the track function is called from inside an effect, the effect will be added to the event’s subscriber list. When the trigger function is called, any effects that have called track will be re-run.

Hopefully that isn’t too complicated to follow. Just in case, I’ll provide a quick code example of what we want to do in the end.

const [track, trigger] = event()

effect(() => {
  // This should be run whenever `trigger` is called.

  track()
  console.log("first effect")
})

effect(() => {
  // But this effect will only be run once, because
  // it didn't subscribe using `track`.

  console.log("second effect")
})

trigger()

These seem pretty easy to implement. First, we’ll create the effect function. We’ll store the currently running effect, if any, in currentEffect.

let currentEffect

function effect(fn) {
  currentEffect = fn
  fn()
  currentEffect = undefined
}

This looks good, but there’s a small issue. If an effect is running inside of another, currentEffect will be cleared instead of preserving the parent effect. This is a pretty simple fix; we’ll just store the parent effect in a variable.

let currentEffect

function effect(fn) {
  const parentEffect = currentEffect
  currentEffect = fn
  fn()
  currentEffect = parentEffect
}

Looks like effect is done. Let’s move on to event now. We’ll store the list of subscribers in a Set called tracking. By using a Set instead of an array, we won’t have to check for duplicate effects, which would be bad. We’ll also return a tuple where the first element is track and the second is trigger. This lets a developer rename the elements to whatever they want using destructuring.

function event() {
  const tracking = new Set()

  return [
    () => {
      if (currentEffect) {
        tracking.add(currentEffect)
      }
    },
    () => {
      tracking.forEach((effect) => effect())
    },
  ]
}

And we’re done! We can’t track values or derived stores, but the core of the library is complete.

Values and memos

Well, complete is a bit of an overstatement. We should really implement value trackers and derived stores ourselves and not force developers to do all the work. We’ll call reactive values signals. They should be easy to implement, as we can just use track and trigger for getters and setters, respectively. We’ll return a tuple of two elements: a function that gets the current value (and tracks the current effect), and a function that sets the current values (and trigger any tracked effects).

function signal(value) {
  const [track, trigger] = event()

  return [
    () => {
      track()
      return value
    },
    (newValue) => {
      value = newValue
      trigger()
    },
  ]
}

Here’s an example of using signals:

const [age, setAge] = signal(14)

effect(() => {
  console.log("current age:", age())
})

setAge(13)
setAge(17)

It almost feels too easy! I used to wonder about how libraries like React and Vue were able to implement reactivity in a library less than a single megabyte, but now I wonder about how it took them that much space when our libraries are so much smaller. Of course, we haven’t implemented memos (derived stores) yet, and I’m sure those will be very complicated…

const [age, setAge] = signal(14)
const doubled = () => 2 * age()

Nope. Because we just used plain functions, we can just make a wrapper function. There is one small worry: if doubled is called in a parent effect, it may end up being re-run when another signal changes, like in this example:

const [age, setAge] = signal(14)
const doubled = () => 2 * age()

const [name, setName] = signal("zSnout")

effect(() => {
  console.log(name(), "is", doubled())
})

setAge(37)
setName("Zachary")

Whoops! doubled is getting called even though it only needs to track age. Of course, there’s an easy solution to this. We’ll create a helper function called memo that creates an effect around doubled and only updates if direct dependencies of doubled change.

function memo(compute) {
  const [get, set] = signal()

  effect(() => set(compute()))

  return get
}

That code block is so small it feels like cheating, but it really works! Our primitives are so good that our helpers barely have to do any work.

Let’s get on to the dreaded measurement of our JavaScript package: how big is it, minified? We want to be able to serve a tiny bundle to our users, so let’s find out. First, I’ll copy and paste our full code here and export all of our functions.

let currentEffect

export function effect(fn) {
  const parentEffect = currentEffect
  currentEffect = fn
  fn()
  currentEffect = parentEffect
}

export function event() {
  const tracking = new Set()

  return [
    () => {
      if (currentEffect) {
        tracking.add(currentEffect)
      }
    },
    () => {
      tracking.forEach((effect) => effect())
    },
  ]
}

export function signal(value) {
  const [track, trigger] = event()

  return [
    () => {
      track()
      return value
    },
    (newValue) => {
      value = newValue
      trigger()
    },
  ]
}

export function memo(compute) {
  const [get, set] = signal()

  effect(() => set(compute()))

  return get
}

Now let’s plug it into esbuild and watch magic happen:

var n;function o(t){let e=n;n=t,t(),n=e}function f(){let t=new Set;return[()=>{n&&t.add(n)},()=>{t.forEach(e=>e())}]}function i(t){let[e,r]=f();return[()=>(e(),t),c=>{t=c,r()}]}function u(t){let[e,r]=i();return o(()=>r(t())),e}export{o as effect,f as event,u as memo,i as signal};

It’s literally 281 characters. That’s not the 2kb of a tiny library, that’s the 0.2kb of a miniscule library!

Making a render function

Our first DOM utility will be a render function. render will be able to render numbers, strings, booleans, signals to those values, and arrays of those values. Our render function will take an item to be rendered and a parent node to put the item inside. We’ll also skip over null and undefined values, as this will help us with conditional rendering in the future. To check null and undefined at once, we’ll do a loose comparison using ==.

function render(item, parent) {
  if (item == null) {
    return
  } else if (typeof item == "function") {
    // TODO
  } else if (Array.isArray(item)) {
    item.forEach((element) => render(element, parent))
  } else if (item instanceof Node) {
    parent.append(item)
  } else {
    parent.append(document.createTextNode(String(item)))
  }
}

You might ask, why do we use String(item) to convert to a string instead of "" + item? Well, converting to a string “explicitly” stops rendering from breaking if the user passes a symbol. This is because implicitly converting a symbol to a string throws an error.

Now, you might be asking why the case for a function was marked with TODO, even though it would be really easy just to write render(item(), parent). Well, the function might be a signal or memo, and we need to reactively observe the function.

Before we can do that, we’re going to make a new function called fragment. fragment will allow us to insert DOM nodes into a container without needing a parent element. How will we do that? First, we’ll make a comment node. This is hidden in the HTML, but we can use it as an anchor to place our elements next to.

function fragment(parent) {
  const comment = document.createComment(" Fragment ")
  parent.append(comment)
}

That’s great, but how do we actually insert elements, taking into account that they might be arbitrary renderables, such as arrays or functions? Well, we can do a small hack. First, let’s set up fragment so that it returns a function which adds renderable items after the comment node.

function fragment(parent) {
  const comment = document.createComment(" Fragment ")
  parent.append(comment)

  return (...items) => {
    // TODO
  }
}

Now let’s look back at our render function. Do you notice how the only DOM method we call is .append? Well, we’re going to make fragment send render an object that only has a .append method and nothing else, and we’ll make it so that .append actually places nodes after the comment.

function fragment(parent) {
  const comment = document.createComment(" Fragment ")
  parent.append(comment)

  return (...items) => {
    render(items, {
      append(node) {
        comment.after(node)
      },
    })
  }
}

Now we can use this inside of render!

function render(item, parent) {
  if (item == null) {
    return
  } else if (typeof item == "function") {
    const replaceNodes = fragment(parent)

    effect(() => {
      replaceNodes(item())
    })
  } else if (Array.isArray(item)) {
    item.forEach((element) => render(element, parent))
  } else if (item instanceof Node) {
    parent.append(item)
  } else {
    parent.append(document.createTextNode(String(item)))
  }
}

Except … there’s a bug. fragment doesn’t clear its old items when we call replaceNodes, so we’ll just end up with duplicates. However, there’s a simple solution to this. We’ll force fragment to keep track of anything passed to its append function and have it remove those nodes whenever replaceNodes is called. We’ll also set its length to 0 before every render, which will clear the array.

function fragment(parent) {
  const comment = document.createComment(" Fragment ")
  parent.append(comment)

  let appendedNodes = []

  return (...items) => {
    appendedNodes.forEach((node) => {
      node.remove()
    })

    // Confused about this syntax? We're just setting an array's length to 0 to
    // delete all its elements without using `.splice`.
    appendedNodes.length = 0

    render(items, {
      // This is object method shorthand. It saves us from typing
      // `append: (node) => ...`.
      append(node) {
        appendedNodes.push(node)
        comment.after(node)
      },
    })
  }
}

Cool! Now let’s create a utility that sets reactive attributes or properties on an element. We’ll decide whether we should set an attribute or property by checking if a given property exists on the element in question. If it does, we’ll set it. Otherwise, we’ll use a plain attribute.

function attr(element, key, value) {
  if (key in element) {
    if (typeof value == "function") {
      effect(() => {
        element[key] = value()
      })
    } else {
      element[key] = value
    }
  } else {
    if (typeof value == "function") {
      effect(() => {
        element.setAttribute(key, value())
      })
    } else {
      element.setAttribute(key, value)
    }
  }
}

Now let’s put all the puzzle pieces together and create a function that constructs a DOM element. These are traditionally called h, which stands for hyperscript.

function h(tag, props, ...children) {
  const element = document.createElement(tag)

  for (const key in props) {
    attr(element, key, props[key])
  }

  render(children, element)

  return element
}

Notice how our h function has the standard signature of React.createElement. This means it is compatible with almost all JSX transforms, so we can use TypeScript or Babel to compile from JSX into our h function.

Before we finish with the core framework, let’s make one more function. You may notice that the way we handle signals in a slow way: we create a new Text node every time they change. To fix this, we’ll add a helper function called text that creates a text node and updates it with the result of a signal.

function text(value) {
  const node = document.createTextNode("")

  if (typeof value == "function") {
    effect(() => {
      node.data = value()
    })
  } else {
    node.data = value
  }

  return node
}

Because our render and h functions accept Nodes as children, we can call text and opt in to a small optimization for our rendering pipeline.

Okay, it’s time to put everything together. Let’s export all our functions and minify everything. First, I’ll put all of our code here.

let currentEffect

export function effect(fn) {
  const parentEffect = currentEffect
  currentEffect = fn
  fn()
  currentEffect = parentEffect
}

export function event() {
  const tracking = new Set()

  return [
    () => {
      if (currentEffect) {
        tracking.add(currentEffect)
      }
    },
    () => {
      tracking.forEach((effect) => effect())
    },
  ]
}

export function signal(value) {
  const [track, trigger] = event()

  return [
    () => {
      track()
      return value
    },
    (newValue) => {
      value = newValue
      trigger()
    },
  ]
}

export function memo(compute) {
  const [get, set] = signal()

  effect(() => set(compute()))

  return get
}

export function render(item, parent) {
  if (item == null) {
    return
  } else if (typeof item == "function") {
    const replaceNodes = fragment(parent)

    effect(() => {
      replaceNodes(item())
    })
  } else if (Array.isArray(item)) {
    item.forEach((element) => render(element, parent))
  } else if (item instanceof Node) {
    parent.append(item)
  } else {
    parent.append(document.createTextNode(String(item)))
  }
}

export function fragment(parent) {
  const comment = document.createComment(" Fragment ")
  parent.append(comment)

  let appendedNodes = []

  return (...items) => {
    appendedNodes.forEach((node) => {
      node.remove()
    })

    appendedNodes.length = 0

    render(items, {
      append(node) {
        appendedNodes.push(node)
        comment.after(node)
      },
    })
  }
}

export function attr(element, key, value) {
  if (key in element) {
    if (typeof value == "function") {
      effect(() => {
        element[key] = value()
      })
    } else {
      element[key] = value
    }
  } else {
    if (typeof value == "function") {
      effect(() => {
        element.setAttribute(key, value())
      })
    } else {
      element.setAttribute(key, value)
    }
  }
}

export function h(tag, attributes, ...children) {
  const element = document.createElement(tag)

  for (const key in attributes) {
    attr(element, key, attributes[key])
  }

  render(children, element)

  return element
}

export function text(value) {
  const node = document.createTextNode("")

  if (typeof value == "function") {
    effect(() => {
      node.data = value()
    })
  } else {
    node.data = value
  }

  return node
}

Time to minify! Let’s check it out…

var c;function f(t){let e=c;c=t,t(),c=e}function i(){let t=new Set;return[()=>
{c&&t.add(c)},()=>{t.forEach(e=>e())}]}function p(t){let[e,n]=i();return[()=>(e
(),t),o=>{t=o,n()}]}function a(t){let[e,n]=p();return f(()=>n(t())),e}function s
(t,e){if(t!=null)if(typeof t=="function"){let n=u(e);f(()=>{n(t())})}else Array.
isArray(t)?t.forEach(n=>s(n,e)):t instanceof Node?e.append(t):e.append(document.
createTextNode(String(t)))}function u(t){let e=document.createComment(
" Fragment ");t.append(e);let n=[];return(...o)=>{n.forEach(r=>{r.remove()}),n.
length=0,s(o,{append(r){n.push(r),e.after(r)}})}}function d(t,e,n){e in t?
typeof n=="function"?f(()=>{t[e]=n()}):t[e]=n:typeof n=="function"?f(()=>{t.
setAttribute(e,n())}):t.setAttribute(e,n)}function x(t,e,...n){let o=document.
createElement(t);for(let r in e)d(o,r,e[r]);return s(n,o),o}function g(t){let 
e=document.createTextNode("");return typeof t=="function"?f(()=>{e.data=t()}):e.
data=t,e}export{d as attr,f as effect,i as event,u as fragment,x as h,a as memo,
s as render,p as signal,g as text};

It’s just over a kilobyte, at 1047 bytes. And we’ve got a fully functioning fine-grained reactivity library that has complete DOM support! It makes you wonder how the React team gets along with 100kb projects.

Bonus features

Now that the core framework is done, let’s add some bonus features. First of all, we should be able to organize our code into components. A component will basically be a function that takes props. We could call them manually, but we want them to be compatible with a JSX transform, so let’s add support for them into h.

First, I’ll model an example component.

function FancyButton({ children }) {
  return h("button", {
    style:
      "border: 1px solid black; background-color: white; font-size: inherit",
    children,
  })
}

const myButton = h(FancyButton, null, "some content")

First, let’s allow users to pass a children prop to h. That’s easy: we just need to check if children exists in our attributes list.

function h(tag, props, ...children) {
  const element = document.createElement(tag)

  if ("children" in props) {
    children = props.children
  }

  for (const key in props) {
    attr(element, key, props[key])
  }

  render(children, element)

  return element
}

The second task is slightly more difficult. Components have different signatures for the children prop, as it might be undefined, a single item, or an array of items.

function MyComponent({ children }) {}

// When no children are passed, `MyComponent` should receive `undefined`.
h(MyComponent, null)

// If one child is passed, `MyComponent` should get that item (not an array).
h(MyComponent, null, "zSnout")

// If multiple are passed, `MyComponent` should receive an array of children.
h(MyComponent, null, "Content #1", "Content #2")

However, we can just check the length of the children parameter. We’ll also assume that, if it is passed, the children prop already has the correct type. Note that we also have to reverse this process when creating DOM nodes. We’ll also add a safety check to make sure props is an object, as if it was a primitive, a lot of things would break.

function h(tag, props, ...children) {
  props ??= {}

  if (typeof tag == "function") {
    if ("children" in props) {
      children = props.children
    } else if (children.length == 0) {
      children = undefined
    } else if (children.length == 1) {
      children = children[0]
    }

    return tag({ ...props, children })
  } else {
    const element = document.createElement(tag)

    if ("children" in props) {
      // This section reverses the effect of changing `props.children`.

      if (props.children == null) {
        children = []
      } else if (!Array.isArray(props.children)) {
        children = [props.children]
      } else {
        children = props.children
      }
    }

    for (const key in props) {
      attr(element, key, props[key])
    }

    render(children, element)

    return element
  }
}

Our function is certainly much heftier now, but it can render components properly, so that’s a plus. However, we’re still missing many things, including event handling, CSS styles, and class toggling. Let’s add a few of those.

For event handling, we’ll use props that start with on:; for CSS properties, we’ll use style:; and for classes, we’ll use class:. We’ll also add a special use prop that calls a function with the created node. Since all of these should work whether we’re creating a component or a DOM element, let’s start by extracting the element returned from our if-else statement. We’ll also stop passing props with a : in them to created DOM nodes.

function h(tag, props, ...children) {
  props ??= {}

  let element

  if (typeof tag == "function") {
    if ("children" in props) {
      children = props.children
    } else if (children.length == 0) {
      children = undefined
    } else if (children.length == 1) {
      children = children[0]
    }

    element = tag({ ...props, children })
  } else {
    element = document.createElement(tag)

    if ("children" in props) {
      if (props.children == null) {
        children = []
      } else if (!Array.isArray(props.children)) {
        children = [props.children]
      } else {
        children = props.children
      }
    }

    for (const key in props) {
      if (!(key.includes(":") || key == "use")) {
        attr(element, key, props[key])
      }
    }

    render(children, element)
  }

  return element
}

Perfect. Now we’ll just add special handling for the props we listed above.

function h(tag, props, ...children) {
  props ??= {}

  let element

  if (typeof tag == "function") {
    if ("children" in props) {
      children = props.children
    } else if (children.length == 0) {
      children = undefined
    } else if (children.length == 1) {
      children = children[0]
    }

    element = tag({ ...props, children })
  } else {
    element = document.createElement(tag)

    if ("children" in props) {
      if (props.children == null) {
        children = []
      } else if (!Array.isArray(props.children)) {
        children = [props.children]
      } else {
        children = props.children
      }
    }

    for (const key in props) {
      if (!(key.includes(":") || key == "use")) {
        attr(element, key, props[key])
      }
    }

    render(children, element)
  }

  for (const key in props) {
    if (key == "use") {
      props[key](element)
    } else if (key.startsWith("class:")) {
      const value = props[key]

      if (typeof value == "function") {
        effect(() => {
          element.classList.toggle(key.slice(6), !!value())
        })
      } else {
        element.classList.toggle(key.slice(6), !!value)
      }
    } else if (key.startsWith("on:")) {
      element.addEventListener(key.slice(3), props[key])
    } else if (key.startsWith("style:")) {
      const value = props[key]

      if (typeof value == "function") {
        effect(() => {
          element.style[key.slice(6)] = value()
        })
      } else {
        element.style[key.slice(6)] = value
      }
    }
  }

  return element
}

Great! It’s time to minify everything, so let’s pop our new h function into esbuild and get the 1.6 kilobyte result.

var s;function o(t){let e=s;s=t,t(),s=e}function l(){let t=new Set;return[()=>
{s&&t.add(s)},()=>{t.forEach(e=>e())}]}function u(t){let[e,n]=l();return[()=>(e
(),t),i=>{t=i,n()}]}function g(t){let[e,n]=u();return o(()=>n(t())),e}function r
(t,e){if(t!=null)if(typeof t=="function"){let n=a(e);o(()=>{n(t())})}else Array.
isArray(t)?t.forEach(n=>r(n,e)):t instanceof Node?e.append(t):e.append(document.
createTextNode(String(t)))}function a(t){let e=document.createComment(
" Fragment ");t.append(e);let n=[];return(...i)=>{n.forEach(f=>{f.remove()}),n.
length=0,r(i,{append(f){n.push(f),e.after(f)}})}}function d(t,e,n){e in t?
typeof n=="function"?o(()=>{t[e]=n()}):t[e]=n:typeof n=="function"?o(()=>{t.
setAttribute(e,n())}):t.setAttribute(e,n)}function x(t,e,...n){e??={};let i;if
(typeof t=="function")"children"in e?n=e.children:n.length==0?n=void 0:n.
length==1&&(n=n[0]),i=t({...e,children:n});else{i=document.createElement(t),
"children"in e&&(e.children==null?n=[]:Array.isArray(e.children)?n=e.children:n=
[e.children]);for(let f in e)f.includes(":")||f=="use"||d(i,f,e[f]);r(n,i)}for
(let f in e)if(f=="use")e[f](i);else if(f.startsWith("class:")){let c=e[f];
typeof c=="function"?o(()=>{i.classList.toggle(f.slice(6),!!c())}):i.classList.
toggle(f.slice(6),!!c)}else if(f.startsWith("on:"))i.addEventListener(f.slice
(3),e[f]);else if(f.startsWith("style:")){let c=e[f];typeof c=="function"?o(()=>
{i.style[f.slice(6)]=c()}):i.style[f.slice(6)]=c}return i}function y(t){let 
e=document.createTextNode("");return typeof t=="function"?o(()=>{e.data=t()}):e.
data=t,e}export{d as attr,o as effect,l as event,a as fragment,x as h,g as memo,
r as render,u as signal,y as text};

Done! We now have a complete web framework. It’s also a much faster framework than something like React or Vue.

Measuring performance

What? How can our tiny library be faster than the longest-living, most used, and largest web frameworks of all time? Well, React and Vue operate using something called the virtual DOM. Basically, if some state, such as a prop, hook, ref, or store, React and Vue will re-run all of your components. You might think that re-creating all of those DOM elements is an expensive operation, and it is.

However, React and Vue have an optimization here and create a virtual representation of the DOM, which is also known as a virtual DOM. Then, these frameworks will look for any differences between the current virtual DOM and the previous one, and apply these changes. Don’t get me wrong: this is much faster than setting document.body.innerHTML or re-creating all the DOM elements. But instead of checking which part needs to be changed, why don’t we just have the developer tell you?

Well, that’s what our framework does. We use signals and memos that update things that depend on their values, not every effect in existence. And by encapsulating anything that updates the DOM in an effect, we effectively only update parts of the DOM that need updating, just without an expensive virtual DOM.

This type of a system is called fine-grained reactivity, which is a concise way of saying that we only update things that need to be updated, instead of updating the entire DOM. It’s a very efficient system and the benchmarks prove it.

Conclusion

Hopefully you learned something new about JavaScript frameworks today. You can even take this knowledge into your next job interview, as your interviewer will definitely be impressed if you write a JS framework from scratch in front of their eyes. Just make sure to put your own spin on it, or they’ll accuse you of copying Solid or Willow!

For those of you who want to improve on our script, here are a few areas that still need work on.

  • Efficient lists: Currently, rendering lists takes a lot of work with our software, as we’d use a memo combined with Array.map, which would recreate each node every time we updated the underlying array. Can you come up with a better implementation that reuses existing nodes?

  • Conditional blocks: So far, the best way to create conditional blocks would be to use the ternary operator. Can you create a component to abstract this? Think about Vue’s v-if directive or Solid’s Show component.

  • Keyed items: Can you make a component that re-renders its content whenever a special key signal changes? This might be useful for the “Efficient lists” item.

  • Awaiting data: Try to make a component that can render a pending, completed, or failed view based on the state of a Promise. Take inspiration from Svelte’s {#await} block; it’s a great implementation.

  • Template syntax: If you’re up for a challenge, try making a tool to convert Svelte or Vue syntax (or another template language) into our code, once you’ve finished the challenges above.

Thanks for reading this blog article on zSnout! Stay tuned for next time, where we’ll be extending Pascal’s Triangle into negative and complex numbers.