In my day job, I work on a JavaScript framework (LWC). And although I’ve been working on it for almost three years, I still feel like a dilettante. When I read about what’s going on in the larger framework world, I often feel overwhelmed by all the things I don’t know.
One of the best ways to learn how something works, though, is to build it yourself. And plus, we gotta keep those “days since last JavaScript framework” memes going. So let’s write our own modern JavaScript framework!
What is a “modern JavaScript framework”?
React is a great framework, and I’m not here to dunk on it. But for the purposes of this post, “modern JavaScript framework” means “a framework from the post-React era” – i.e. Lit, Solid, Svelte, Vue, etc.
React has dominated the frontend landscape for so long that every newer framework has grown up in its shadow. These frameworks were all heavily inspired by React, but they’ve evolved away from it in surprisingly similar ways. And although React itself has continued innovating, I find that the post-React frameworks are more similar to each other than to React nowadays.
To keep things simple, I’m also going to avoid talking about server-first frameworks like Astro, Marko, and Qwik. These frameworks are excellent in their own way, but they come from a slightly different intellectual tradition compared to the client-focused frameworks. So for this post, let’s only talk about client-side rendering.
What sets modern frameworks apart?
From my perspective, the post-React frameworks have all converged on the same foundational ideas:
- Using reactivity (e.g. signals) for DOM updates.
- Using cloned templates for DOM rendering.
- Using modern web APIs like
<template>
andProxy
, which make all of the above easier.
Now to be clear, these frameworks differ a lot at the micro level, and in how they handle things like web components, compilation, and user-facing APIs. Not all frameworks even use Proxy
s. But broadly speaking, most framework authors seem to agree on the above ideas, or they’re moving in that direction.
So for our own framework, let’s try to do the bare minimum to implement these ideas, starting with reactivity.
Reactivity
It’s often said that “React is not reactive”. What this means is that React has a more pull-based rather than a push-based model. To grossly oversimplify things: in the worst case, React assumes that your entire virtual DOM tree needs to be rebuilt from scratch, and the only way to prevent these updates is to implement React.memo
(or in the old days, shouldComponentUpdate
).
Using a virtual DOM mitigates some of the cost of the “blow everything away and start from scratch” strategy, but it doesn’t fully solve it. And asking developers to write the correct memo code is a losing battle. (See React Forget for an ongoing attempt to solve this.)
Instead, modern frameworks use a push-based reactive model. In this model, individual parts of the component tree subscribe to state updates and only update the DOM when the relevant state changes. This prioritizes a “performant by default” design in exchange for some upfront bookkeeping cost (especially in terms of memory) to keep track of which parts of the state are tied to which parts of the UI.
Note that this technique is not necessarily incompatible with the virtual DOM approach: tools like Preact Signals and Million show that you can have a hybrid system. This is useful if your goal is to keep your existing virtual DOM framework (e.g. React) but to selectively apply the push-based model for more performance-sensitive scenarios.
For this post, I’m not going to rehash the details of signals themselves, or subtler topics like fine-grained reactivity, but I am going to assume that we’ll use a reactive system.
Note: there are lots of nuances when talking about what qualifies as “reactive.” My goal here is to contrast React with the post-React frameworks, especially Solid, Svelte v5 in “runes” mode, and Vue Vapor.
Cloning DOM trees
For a long time, the collective wisdom in JavaScript frameworks was that the fastest way to render the DOM is to create and mount each DOM node individually. In other words, you use APIs like createElement
, setAttribute
, and textContent
to build the DOM piece-by-piece:
const div = document.createElement('div') div.setAttribute('class', 'blue') div.textContent = 'Blue!'
One alternative is to just shove a big ol’ HTML string into innerHTML
and let the browser parse it for you:
const container = document.createElement('div') container.innerHTML = ` <div class="blue">Blue!</div> `
This naïve approach has a big downside: if there is any dynamic content in your HTML (for instance, red
instead of blue
), then you would need to parse HTML strings over and over again. Plus, you are blowing away the DOM with every update, which would reset state such as the value
of <input>
s.
Note: using innerHTML
also has security implications. But for the purposes of this post, let’s assume that the HTML content is trusted. 1
At some point, though, folks figured out that parsing the HTML once and then calling cloneNode(true)
on the whole thing is pretty danged fast:
const template = document.createElement('template') template.innerHTML = ` <div class="blue">Blue!</div> ` template.content.cloneNode(true) // this is fast!
Here I’m using a <template>
tag, which has the advantage of creating “inert” DOM. In other words, things like <img>
or <video autoplay>
don’t automatically start downloading anything.
How fast is this compared to manual DOM APIs? To demonstrate, here’s a small benchmark. Tachometer reports that the cloning technique is about 50% faster in Chrome, 15% faster in Firefox, and 10% faster in Safari. (This will vary based on DOM size and number of iterations, but you get the gist.)
What’s interesting is that <template>
is a new-ish browser API, not available in IE11, and originally designed for web components. Somewhat ironically, this technique is now used in a variety of JavaScript frameworks, regardless of whether they use web components or not.
Note: for reference, here is the use of cloneNode
on <template>
s in Solid, Vue Vapor, and Svelte v5.
There is one major challenge with this technique, which is how to efficiently update dynamic content without blowing away DOM state. We’ll cover this later when we build our toy framework.
Modern JavaScript APIs
We’ve already encountered one new API that helps a lot, which is <template>
. Another one that’s steadily gaining traction is Proxy
, which can make building a reactivity system much simpler.
When we build our toy example, we’ll also use tagged template literals to create an API like this:
const dom = html` <div>Hello ${ name }!</div> `
Not all frameworks use this tool, but notable ones include Lit, HyperHTML, and ArrowJS. Tagged template literals can make it much simpler to build ergonomic HTML templating APIs without needing a compiler.
Step 1: building reactivity
Reactivity is the foundation upon which we'll build the rest of the framework. Reactivity will define how state is managed, and how the DOM updates when state changes.
Let's start with some "dream code" to illustrate what we want:
const state = {} state.a = 1 state.b = 2 createEffect(() => { state.sum = state.a + state.b })
Basically, we want a “magic object” called state
, with two props: a
and b
. And whenever those props change, we want to set sum
to be the sum of the two.
Assuming we don’t know the props in advance (or have a compiler to determine them), a plain object will not suffice for this. So let’s use a Proxy
, which can react whenever a new value is set:
const state = new Proxy({}, { get(obj, prop) { onGet(prop) return obj[prop] }, set(obj, prop, value) { obj[prop] = value onSet(prop, value) return true } })
Right now, our Proxy
doesn’t do anything interesting, except give us some onGet
and onSet
hooks. So let’s make it flush updates after a microtask:
let queued = false function onSet(prop, value) { if (!queued) { queued = true queueMicrotask(() => { queued = false flush() }) } }
Note: if you’re not familiar with queueMicrotask
, it’s a newer DOM API that’s basically the same as Promise.resolve().then(...)
, but with less typing.
Why flush updates? Mostly because we don’t want to run too many computations. If we update whenever both a
and b
change, then we’ll uselessly compute the sum
twice. By coalescing the flush into a single microtask, we can be much more efficient.
Next, let’s make flush
update the sum:
function flush() { state.sum = state.a + state.b }
This is great, but it’s not yet our “dream code.” We’ll need to implement createEffect
so that the sum
is computed only when a
and b
change (and not when something else changes!).
To do this, let’s use an object to keep track of which effects need to be run for which props:
const propsToEffects = {}
Next comes the crucial part! We need to make sure that our effects can subscribe to the right props. To do so, we’ll run the effect, note any get
calls it makes, and create a mapping between the prop and the effect.
To break it down, remember our “dream code” is:
createEffect(() => { state.sum = state.a + state.b })
When this function runs, it calls two getters: state.a
and state.b
. These getters should trigger the reactive system to notice that the function relies on the two props.
To make this happen, we’ll start with a simple global to keep track of what the “current” effect is:
let currentEffect
Then, the createEffect
function will set this global before calling the function:
function createEffect(effect) { currentEffect = effect effect() currentEffect = undefined }
The important thing here is that the effect is immediately invoked, with the global currentEffect
being set in advance. This is how we can track whatever getters it might be calling.
Now, we can implement the onGet
in our Proxy
, which will set up the mapping between the global currentEffect
and the property:
function onGet(prop) { const effects = propsToEffects[prop] ?? (propsToEffects[prop] = []) effects.push(currentEffect) }
After this runs once, propsToEffects
should look like this:
{ "a": [theEffect], "b": [theEffect] }
…where theEffect
is the “sum” function we want to run.
Next, our onSet
should add any effects that need to be run to a dirtyEffects
array:
const dirtyEffects = [] function onSet(prop, value) { if (propsToEffects[prop]) { dirtyEffects.push(...propsToEffects[prop]) // ... } }
At this point, we have all the pieces in place for flush
to call all the dirtyEffects
:
function flush() { while (dirtyEffects.length) { dirtyEffects.shift()() } }
Putting it all together, we now have a fully functional reactivity system! You can play around with it yourself and try setting state.a
and state.b
in the DevTools console – the state.sum
will update whenever either one changes.
Now, there are plenty of advanced cases that we’re not covering here:
- Using
try
/catch
in case an effect throws an error - Avoiding running the same effect twice
- Preventing infinite cycles
- Subscribing effects to new props on subsequent runs (e.g. if certain getters are only called in an
if
block)
However, this is more than enough for our toy example. Let’s move on to DOM rendering.
Step 2: DOM rendering
We now have a functional reactivity system, but it’s essentially “headless.” It can track changes and compute effects, but that’s about it.
At some point, though, our JavaScript framework needs to actually render some DOM to the screen. (That’s kind of the whole point.)
For this section, let’s forget about reactivity for a moment and imagine we’re just trying to build a function that can 1) build a DOM tree, and 2) update it efficiently.
Once again, let’s start off with some dream code:
function render(state) { return html` <div class="${state.color}">${state.text}</div> ` }
As I mentioned, I’m using tagged template literals, ala Lit, because I found them to be a nice way to write HTML templates without needing a compiler. (We’ll see in a moment why we might actually want a compiler instead.)
We’re re-using our state
object from before, this time with a color
and text
property. Maybe the state is something like:
state.color = 'blue' state.text = 'Blue!'
When we pass this state
into render
, it should return the DOM tree with the state applied:
<div class="blue">Blue!</div>
Before we go any further, though, we need a quick primer on tagged template literals. Our html
tag is just a function that receives two arguments: the tokens
(array of static HTML strings) and expressions
(the evaluated dynamic expressions):
function html(tokens, ...expressions) { }
In this case, the tokens
are (whitespace removed):
[ "<div class=\"", "\">", "</div>" ]
And the expressions
are:
[ "blue", "Blue!" ]
The tokens
array will always be exactly 1 longer than the expressions
array, so we can trivially zip them up together:
const allTokens = tokens .map((token, i) => (expressions[i - 1] ?? '') + token)
This will give us an array of strings:
[ "<div class=\"", "blue\">", "Blue!</div>" ]
We can join these strings together to make our HTML:
const htmlString = allTokens.join('')
And then we can use innerHTML
to parse it into a <template>
:
function parseTemplate(htmlString) { const template = document.createElement('template') template.innerHTML = htmlString return template }
This template contains our inert DOM (technically a DocumentFragment
), which we can clone at will:
const cloned = template.content.cloneNode(true)
Of course, parsing the full HTML whenever the html
function is called would not be great for performance. Luckily, tagged template literals have a built-in feature that will help out a lot here.
For every unique usage of a tagged template literal, the tokens
array is always the same whenever the function is called – in fact, it’s the exact same object!
For example, consider this case:
function sayHello(name) { return html`<div>Hello ${name}</div>` }
Whenever sayHello
is called, the tokens
array will always be identical:
[ "<div>Hello ", "</div>" ]
The only time tokens
will be different is for completely different locations of the tagged template:
html`<div></div>` html`<span></span>` // Different from above
We can use this to our advantage by using a WeakMap
to keep a mapping of the tokens
array to the resulting template
:
const tokensToTemplate = new WeakMap() function html(tokens, ...expressions) { let template = tokensToTemplate.get(tokens) if (!template) { // ... template = parseTemplate(htmlString) tokensToTemplate.set(tokens, template) } return template }
This is kind of a mind-blowing concept, but the uniqueness of the tokens
array essentially means that we can ensure that each call to html`...`
only parses the HTML once.
Next, we just need a way to update the cloned DOM node with the expressions
array (which is likely to be different every time, unlike tokens
).
To keep things simple, let’s just replace the expressions
array with a placeholder for each index:
const stubs = expressions.map((_, i) => `__stub-${i}__`)
If we zip this up like before, it will create this HTML:
<div class="__stub-0__"> __stub-1__ </div>
We can write a simple string replacement function to replace the stubs:
function replaceStubs (string) { return string.replaceAll(/__stub-(\d+)__/g, (_, i) => ( expressions[i] )) }
And now whenever the html
function is called, we can clone the template and update the placeholders:
const element = cloned.firstElementChild for (const { name, value } of element.attributes) { element.setAttribute(name, replaceStubs(value)) } element.textContent = replaceStubs(element.textContent)
Note: we are using firstElementChild
to grab the first top-level element in the template. For our toy framework, we’re assuming there’s only one.
Now, this is still not terribly efficient – notably, we are updating textContent
and attributes that don’t necessarily need to be updated. But for our toy framework, this is good enough.
We can test it out by rendering with different state
:
document.body.appendChild(render({ color: 'blue', text: 'Blue!' })) document.body.appendChild(render({ color: 'red', text: 'Red!' }))
This works!
Step 3: combining reactivity and DOM rendering
Since we already have a createEffect
from the rendering system above, we can now combine the two to update the DOM based on the state:
const container = document.getElementById('container') createEffect(() => { const dom = render(state) if (container.firstElementChild) { container.firstElementChild.replaceWith(dom) } else { container.appendChild(dom) } })
This actually works! We can combine this with the “sum” example from the reactivity section by merely creating another effect to set the text
:
createEffect(() => { state.text = `Sum is: ${state.sum}` })
This renders “Sum is 3”:
You can play around with this toy example. If you set state.a = 5
, then the text will automatically update to say “Sum is 7.”
Next steps
There are lots of improvements we could make to this system, especially the DOM rendering bit.
Most notably, we are missing a way to update content for elements inside a deep DOM tree, e.g.:
<div class="${color}"> <span>${text}</span> </div>
For this, we would need a way to uniquely identify every element inside of the template. There are lots of ways to do this:
- Lit, when parsing HTML, uses a system of regexes and character matching to determine whether a placeholder is within an attribute or text content, plus the index of the target element (in depth-first
TreeWalker
order). - Frameworks like Svelte and Solid have the luxury of parsing the entire HTML template during compilation, which provides the same information. They also generate code that calls
firstChild
andnextSibling
to traverse the DOM to find the element to update.
Note: traversing with firstChild
and nextSibling
is similar to the TreeWalker
approach, but more efficient than element.children
. This is because browsers use linked lists under the hood to represent the DOM.
Whether we decided to do Lit-style client-side parsing or Svelte/Solid-style compile-time parsing, what we want is some kind of mapping like this:
[ { elementIndex: 0, // <div> above attributeName: 'class', stubIndex: 0 // index in expressions array }, { elementIndex: 1 // <span> above textContent: true, stubIndex: 1 // index in expressions array } ]
These bindings would tell us exactly which elements need to be updated, which attribute (or textContent
) needs to be set, and where to find the expression
to replace the stub.
The next step would be to avoid cloning the template every time, and to just directly update the DOM based on the expressions
. In other words, we not only want to parse once – we want to only clone and set up the bindings once. This would reduce each subsequent update to the bare minimum of setAttribute
and textContent
calls.
Note: you may wonder what the point of template-cloning is, if we end up needing to call setAttribute
and textContent
anyway. The answer is that most HTML templates are largely static content with a few dynamic “holes.” By using template-cloning, we clone the vast majority of the DOM, while only doing extra work for the “holes.” This is the key insight that makes this system work so well.
Another interesting pattern to implement would be iterations (or repeaters), which come with their own set of challenges, like reconciling lists between updates and handling “keys” for efficient replacement.
I’m tired, though, and this blog post has gone on long enough. So I leave the rest as an exercise to the reader!
Conclusion
So there you have it. In the span of one (lengthy) blog post, we’ve implemented our very own JavaScript framework. Feel free to use this as the foundation for your brand-new JavaScript framework, to release to the world and enrage the Hacker News crowd.
Personally I found this project very educational, which is partly why I did it in the first place. I was also looking to replace the current framework for my emoji picker component with a smaller, more custom-built solution. In the process, I managed to write a tiny framework that passes all the existing tests and is ~6kB smaller than the current implementation, which I’m pretty proud of.
In the future, I think it would be neat if browser APIs were full-featured enough to make it even easier to build a custom framework. For example, the DOM Part API proposal would take out a lot of the drudgery of the DOM parsing-and-replacement system we built above, while also opening the door to potential browser performance optimizations. I could also imagine (with some wild gesticulation) that an extension to Proxy
could make it easier to build a full reactivity system without worrying about details like flushing, batching, or cycle detection.
If all those things were in place, then you could imagine effectively having a “Lit in the browser,” or at least a way to quickly build your own “Lit in the browser.” In the meantime, I hope that this small exercise helped to illustrate some of the things framework authors think about, and some of the machinery under the hood of your favorite JavaScript framework.
Thanks to Pierre-Marie Dartus for feedback on a draft of this post.
Footnotes
1. Now that we’ve built the framework, you can see why the content passed to innerHTML
can be considered trusted. All HTML tokens either come from tagged template literals (in which case they’re fully static and authored by the developer) or are placeholders (which are also written by the developer). User content is only set using setAttribute
or textContent
, which means that no HTML sanitization is required to avoid XSS attacks. Although you should probably just use CSP anyway!
Posted by James on December 3, 2023 at 7:30 AM
Thanks! I’ve been looking for an intro to building my own framework. Great write up!
Posted by Ariel on December 4, 2023 at 1:27 AM
Amazing post, learned a lot from this! Thank you
Posted by codeyeti on December 4, 2023 at 3:53 AM
I think there’s a mistake in the code (although it doesn’t affect the results), which is that when you run the following:
This gives a result with 3 entries, not 5. The results are:
Calling join on this does get the same result, though
Posted by Nolan Lawson on December 4, 2023 at 7:42 AM
D’oh, you’re right! Fixed in the post. Thanks!
Posted by Journal of the Week | My Journal on December 6, 2023 at 9:49 PM
[…] 12/7/2023 How to build a Javascript Framework […]
Posted by Zero on December 12, 2023 at 12:17 AM
Thank you for writing a amazing post. I’d like to post a Korean translation of this post on my blog, is that okay?
Posted by Nolan Lawson on December 12, 2023 at 9:29 PM
I have no problem with translations as long as there is a link back to the original post. Please go ahead. 🙂
Posted by Augustin Riedinger on December 12, 2023 at 1:17 AM
This is a very good vulgarization post, but I’m not comfortable with your classification setting React as a “pre-modern framework”.
The genius in React which has been re-used on and on (which Angular did not include at that time) was the single-way data binding with event listening to communicate backward. It made it all simpler and closer to how the DOM works inherently.
Besides, dropping the virtual DOM and using the observable API instead of React’s functional approach is anecdotal and it has more to do with coding style preferences than actual framework features.
I like Vue and siblings for their simplicity but I tend to find React more robust for large projects.
Posted by Nolan Lawson on December 12, 2023 at 9:44 PM
React is 10 years old (released 2013), so at some point we need a way of talking about what came after it. 🙂 I chose “modern” as a slightly provocative word, and because I couldn’t think of a better one.
I won’t dispute that React is a great framework that had a huge impact on the frontend landscape. The new techniques are just that – new – and it remains to be seen if any of them will truly supplant React’s model. Meanwhile, React is innovating in its own direction (React Server Components, concurrent rendering, etc.). So the future is still totally open, and anyway, React isn’t going anywhere anytime soon.
Posted by Falk on December 23, 2023 at 11:53 PM
I read many about tagged template literals and do not understand it.
Now, with your blog post, I understand it and the parameter🎉
Thank you for this great article.
Posted by Ox-Harris on December 27, 2023 at 3:45 PM
Nolan, you’ll my fun framework 😅: https://github.com/webqit/oohtml
Posted by Ashish on December 28, 2023 at 12:11 PM
I guess some CPU can be saved by unique effects.
if(propsToEffects[prop]) {
propsToEffects[prop].forEach(effect => {
dirtyEffects.includes(effect) || dirtyEffects.push(effect)
})
}
Posted by Hot New Web Dev - December 2023 on December 30, 2023 at 10:41 AM
[…] to move on to using a framework but don’t fully understand how it works. You may find that building a JavaScript framework will help fill in the missing […]
Posted by [Js-Craft #47] The Guide to React book, CSS Grid, using console.trace(), image color inverting in Javascript and more – Js Craft on February 2, 2024 at 11:03 PM
[…] Let’s learn how modern JavaScript frameworks work by building one […]
Posted by David Fahlander on February 15, 2024 at 11:38 PM
It’s always pleasant reading your texts – I get a feeling of clarity sometimes – can’t say what exactly it is but I suppose it’s the way you write. Nice reading! Especially the reasoning about what modern frameworks have in common and how they distinguishes from react.
David
Posted by Nolan Lawson on February 16, 2024 at 11:52 AM
Thanks for the kind comment, David!
Posted by Vanilla JavaScript, Libraries, And The Quest For Stateful DOM Rendering — Smashing Magazine on February 25, 2024 at 10:38 PM
[…] was somewhat dire back then and remains subpar in some environments.5 Nolan Lawson’s “Let’s learn how modern JavaScript frameworks work by building one” provides plenty of valuable insights on that topic. For even more details, lit-html’s […]