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
and
Proxy
, 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: 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 useMemo
(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.
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 = `Blue!`
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 s.
Note: using innerHTML
also has security implications. But for the purposes of this post, let’s assume that the HTML content is trusted.
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 = `Blue!` template.content.cloneNode(true) // this is fast!
Here I’m using a tag, which has the advantage of creating “inert” DOM. In other words, things like
or
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 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 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 . Another one that’s steadily gaining traction is
Proxy
, which can make building 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`Hello ${ name }!`
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 currentEffect
being set globally in advance. This is how we can track whatever getters the effect 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