Build Your Own Frontend Framework
Every frontend framework solves the same fundamental problem: keeping what the user sees in sync with what your application knows. When data changes, the screen should update. Sounds simple. It isn't.
In this article, we'll build a complete framework from scratch. Not a toy, a real virtual DOM with diffing, a state manager with reducers, stateful components, and parent-child communication. Along the way, you'll understand why React does things the way it does, and why some frameworks chose differently.
What we're building
By the end, you'll have a framework with:
- A virtual DOM representation using plain JavaScript objects
- Three node types: elements, text, and fragments
- The
h()function for creating virtual nodes - Mounting that turns virtual DOM into real DOM
- Destruction with proper cleanup
- Reconciliation that diffs trees and patches efficiently
- Keyed lists for stable identity
- A dispatcher pattern for state management
- Stateful components with lifecycle methods
- Parent-child communication (props down, events up)
The Problem
How do we update the screen when data changes, without manually manipulating every DOM element?
Let's start with vanilla JavaScript. Here's a simple counter:
let count = 0;
const button = document.createElement('button');
button.textContent = 'Count: 0';
button.onclick = () => {
count++;
button.textContent = 'Count: ' + count;
};
document.body.appendChild(button);This works. But notice something: the event handler has two jobs. It updates the data (count++) and updates the DOM (button.textContent = ...). These concerns are tangled together.
Now imagine a real application. A todo list. A shopping cart. A form with validation. Every event handler needs to know exactly which DOM nodes to update. Add an item? Create an <li>, append it. Delete an item? Find the right element, remove it. Reorder items? Now you're doing DOM surgery.
The codebase becomes a maze of createElement, appendChild, and removeChild calls. Change the HTML structure? Update every handler. Good luck.
Separate describing the UI from updating it. You declare what the UI should look like given the current state. The framework figures out how to make it happen.
The Virtual DOM
The virtual DOM is a JavaScript representation of the real DOM. Instead of creating actual <div> and <button> elements, you create plain objects that describe them.
// Real DOM (expensive)
const div = document.createElement('div');
div.className = 'card';
div.appendChild(document.createTextNode('Hello'));
// Virtual DOM (cheap)
const vnode = {
type: 'element',
tag: 'div',
props: { class: 'card' },
children: [
{ type: 'text', value: 'Hello' }
]
};Same information. Different format. The virtual DOM contains everything needed to build the real DOM: element types, attributes, and nested structure.
Why bother with the indirection?
Real DOM nodes are heavy. Open your browser console and type:
Object.keys(document.createElement('div')).length
// → 200+ propertiesEvery DOM element carries layout info, event handlers, ARIA attributes, and browser internals. Virtual nodes have maybe four properties. Creating thousands of them is nearly free.
The strategy: create virtual nodes, compare them, figure out what changed, then touch the real DOM only where necessary. The expensive part is minimized.
Three Types of Virtual Nodes
Not everything in the DOM is an element. We need three types of virtual nodes to represent all the possibilities:
Element Nodes
The workhorses. They represent HTML elements with a tag name, attributes, and children.
h('button', { class: 'btn', onclick: handleClick }, ['Click me'])Text Nodes
The simplest type. Just text, no tag, no attributes. We need a helper function because text nodes don't go through h():
function hString(str) {
return { type: 'text', value: str };
}
hString('Hello World')
// → { type: 'text', value: 'Hello World' }Fragment Nodes
Fragments group multiple elements without adding a wrapper to the DOM. Useful when a component needs to return several siblings:
function hFragment(children) {
return {
type: 'fragment',
children: children.map(child =>
typeof child === 'string' ? hString(child) : child
)
};
}
// Returns three <li> elements without a wrapper
hFragment([
h('li', {}, ['Item 1']),
h('li', {}, ['Item 2']),
h('li', {}, ['Item 3'])
])React uses <>...</> for fragments. Vue uses <template>. Same concept, different syntax.
Why do fragments matter?
The DOM is a tree. Every node except the root needs a parent. If your component returns three <li> elements, you need something to hold them. A fragment is that something, but it doesn't add an extra <div> to your HTML.
During mounting, a fragment's el property points to the parent element, not a new element. When destroying, you destroy the children, you don't remove the parent because you didn't create it.
The h() Function
Writing virtual DOM objects by hand is tedious. We need a helper function. By convention, it's called h() (short for "hyperscript").
The function takes three arguments:
- tag: The element type ('div', 'button', 'span')
- props: Attributes and event handlers
- children: Nested elements or text
Here's the implementation:
function h(tag, props = {}, children = []) {
return {
type: 'element',
tag,
props,
children: children
.filter(child => child != null) // Remove nulls (conditional rendering)
.map(child =>
typeof child === 'string'
? { type: 'text', value: child }
: child
)
};
}Two conveniences baked in: null children are filtered out (for conditional rendering), and strings are automatically converted to text nodes.
What about JSX?
JSX is just syntactic sugar over h() calls. When you write:
<button class="primary">Click</button>Babel (or your build tool) transforms it to:
h('button', { class: 'primary' }, ['Click'])React uses React.createElement instead of h, but the idea is identical.
Mounting: Virtual to Real
Once you have a virtual DOM tree, you need to turn it into real DOM nodes and insert them into the page. This is called mounting.
Step through and watch how mountDOM dispatches based on node type:
Each type has its own handler. Text nodes create document.createTextNode(). Elements create document.createElement(), then mount children recursively. Fragments skip element creation entirely and mount children directly into the parent.
function mountDOM(vdom, parent) {
switch (vdom.type) {
case 'text':
createTextNode(vdom, parent);
break;
case 'element':
createElementNode(vdom, parent);
break;
case 'fragment':
createFragmentNodes(vdom, parent);
break;
}
}The el Property
Every mounted virtual node gets an el property pointing to its real DOM counterpart. This reference is essential:
- The reconciliation algorithm needs to know which DOM node to update
destroyDOM()needs it to remove the element- Event listeners need to be attached to the correct element
Setting Attributes
When mounting an element, we need to handle different types of properties:
The addProps() function handles several cases:
function addProps(el, props, vdom) {
const { on: events, class: className, style, ...attrs } = props;
// 1. Event listeners (on: { click: handler })
if (events) {
vdom.listeners = {};
for (const [event, handler] of Object.entries(events)) {
el.addEventListener(event, handler);
vdom.listeners[event] = handler;
}
}
// 2. Class (string or array)
if (className) {
const classes = Array.isArray(className) ? className : [className];
el.classList.add(...classes);
}
// 3. Inline styles (object)
if (style) {
for (const [prop, value] of Object.entries(style)) {
el.style[prop] = value;
}
}
// 4. Regular attributes
for (const [name, value] of Object.entries(attrs)) {
el.setAttribute(name, value);
}
}We save event listeners in vdom.listeners so we can remove them later during cleanup or when patching.
Destroying: Cleanup Matters
When a view is no longer needed, you remove its DOM nodes. But you can't just call remove(), you need to clean up event listeners to prevent memory leaks.
function destroyDOM(vdom) {
switch (vdom.type) {
case 'text':
vdom.el.remove();
break;
case 'element':
// 1. Remove event listeners first
if (vdom.listeners) {
for (const [event, handler] of Object.entries(vdom.listeners)) {
vdom.el.removeEventListener(event, handler);
}
}
// 2. Destroy children recursively
vdom.children.forEach(destroyDOM);
// 3. Remove from DOM
vdom.el.remove();
break;
case 'fragment':
// Fragments just destroy children (they didn't create an element)
vdom.children.forEach(destroyDOM);
break;
}
// Clear the reference
delete vdom.el;
}The order matters: event listeners first, then children, then the element itself. For fragments, you only destroy children, the fragment didn't create the parent.
Reconciliation: The Clever Part
When state changes, how do we update only what's different instead of rebuilding everything?
The naive approach: destroy the entire DOM tree and mount a new one. It works, but it's wasteful. Focus is lost. Scroll positions reset. Animations restart. The screen flickers.
The smart approach: compare the old virtual tree to the new one, then apply only the differences to the real DOM. This is reconciliation, and it's where frameworks earn their keep.
When Are Two Nodes Equal?
The first question at every step: are these two virtual nodes "equal"? If yes, reuse the DOM node and patch its properties. If no, destroy and recreate.
- Text nodes are always equal. Just update
el.nodeValue. - Fragment nodes are always equal. They're just containers.
- Element nodes are equal if their tags match. A
<div>can't become a<span>, you have to destroy and recreate.
function areNodesEqual(oldNode, newNode) {
// Different types? Not equal
if (oldNode.type !== newNode.type) return false;
// For elements, tags must match
if (oldNode.type === 'element') {
return oldNode.tag === newNode.tag;
}
// Text and fragments are always "equal"
return true;
}The Patching Algorithm
function patchDOM(oldVdom, newVdom, parent) {
// 1. Different types? Replace entirely
if (!areNodesEqual(oldVdom, newVdom)) {
const index = Array.from(parent.childNodes).indexOf(oldVdom.el);
destroyDOM(oldVdom);
mountDOM(newVdom, parent, index);
return newVdom;
}
// 2. Same type: reuse the DOM node
newVdom.el = oldVdom.el;
// 3. Handle each type
switch (newVdom.type) {
case 'text':
patchText(oldVdom, newVdom);
break;
case 'element':
patchElement(oldVdom, newVdom);
break;
case 'fragment':
patchChildren(oldVdom, newVdom);
break;
}
return newVdom;
}
function patchText(oldVdom, newVdom) {
if (oldVdom.value !== newVdom.value) {
newVdom.el.nodeValue = newVdom.value;
}
}
function patchElement(oldVdom, newVdom) {
patchProps(oldVdom, newVdom);
patchChildren(oldVdom, newVdom);
}Try It
Add, remove, and reorder items. Notice how existing items animate smoothly instead of being destroyed and recreated:
Keys: The Identity Problem
Without keys, how does the framework know which item is which when the list changes?
Watch what happens when you remove the first item from a list without keys. Type something in each input, then remove the first item:
Without keys, the framework matches children by position. Remove item A from [A, B, C], and the framework thinks: "item at position 0 changed from A to B, item at position 1 changed from B to C, item at position 2 was removed."
With keys, the framework matches by identity: "A was removed, B and C stayed." This matters when items have state (like input values) or animations.
Updating areNodesEqual() for Keys
function areNodesEqual(oldNode, newNode) {
if (oldNode.type !== newNode.type) return false;
if (oldNode.type === 'element') {
// Check both tag AND key
return oldNode.tag === newNode.tag
&& oldNode.props?.key === newNode.props?.key;
}
return true;
}Don't use array index as key
Using index as a key is almost useless. When you remove item 0, the old index 1 becomes the new index 0. The "key" changes even though it's the same item.
Use stable identifiers: database IDs, UUIDs, or any value that stays constant for the lifetime of the item.
State Management
We can describe UI and update it efficiently. But when should we update? Something needs to watch for state changes and trigger re-renders.
The Dispatcher Pattern
Instead of mutating state directly, you send commands through a dispatcher. The dispatcher finds the right handler, updates state, and notifies the renderer.
Three pieces working together:
- Commands: Named actions with optional payloads ("increment", "set")
- Reducers: Pure functions that compute new state from old state + payload
- Dispatcher: Routes commands to reducers, triggers re-renders
class Dispatcher {
#handlers = new Map();
#afterHandlers = [];
subscribe(command, handler) {
if (!this.#handlers.has(command)) {
this.#handlers.set(command, []);
}
this.#handlers.get(command).push(handler);
// Return unsubscribe function
return () => {
const handlers = this.#handlers.get(command);
const index = handlers.indexOf(handler);
handlers.splice(index, 1);
};
}
dispatch(command, payload) {
const handlers = this.#handlers.get(command) || [];
for (const handler of handlers) {
handler(payload);
}
// Notify after-handlers (the renderer)
for (const after of this.#afterHandlers) {
after();
}
}
afterEveryCommand(handler) {
this.#afterHandlers.push(handler);
return () => {
const index = this.#afterHandlers.indexOf(handler);
this.#afterHandlers.splice(index, 1);
};
}
}The key insight: afterEveryCommand is where re-rendering happens. Subscribe the render function there, and the view updates automatically whenever state changes.
Stateful Components
So far, we've treated state as a single global object. That works for small apps, but as applications grow, you want components to manage their own state.
A counter tracks its count. A modal knows if it's open. A form manages its field values. Each component becomes self-contained.
The defineComponent Factory
We need a way to define components with internal state. The pattern: a factory function that takes a description and returns a class:
function defineComponent({ state: stateFn, render, ...methods }) {
return class Component {
#vdom = null;
#hostEl = null;
#isMounted = false;
constructor(props = {}) {
this.props = props;
this.state = stateFn ? stateFn(props) : {};
// Bind custom methods to the instance
for (const [name, method] of Object.entries(methods)) {
this[name] = method.bind(this);
}
}
render() {
return render.call(this);
}
mount(hostEl) {
if (this.#isMounted) {
throw new Error('Component is already mounted');
}
this.#hostEl = hostEl;
this.#vdom = this.render();
mountDOM(this.#vdom, hostEl);
this.#isMounted = true;
}
unmount() {
if (!this.#isMounted) {
throw new Error('Component is not mounted');
}
destroyDOM(this.#vdom);
this.#vdom = null;
this.#isMounted = false;
}
updateState(newState) {
this.state = { ...this.state, ...newState };
this.#patch();
}
#patch() {
const newVdom = this.render();
this.#vdom = patchDOM(this.#vdom, newVdom, this.#hostEl);
}
};
}Each instance has its own state, its own #vdom, its own lifecycle. Update one counter, the others don't notice.
Parent-Child Communication
Real applications have component hierarchies. A TodoList renders multiple TodoItem components. A Form contains Input and Button components. How do they communicate?
Props Flow Down
Parents pass data to children through props. The child doesn't fetch data, it renders what it's given:
// Parent's render method
render() {
return h('div', {}, [
h(ChildComponent, {
count: this.state.count,
user: this.state.currentUser,
})
]);
}Events Flow Up
The child doesn't modify parent state directly. Instead, it emits events. The parent listens and decides what to do:
// Child component
render() {
return h('button', {
on: { click: () => this.emit('increment', { amount: 1 }) }
}, ['+1']);
}
// Parent component
render() {
return h(IncrementButton, {
on: {
increment: (payload) => {
this.updateState({
count: this.state.count + payload.amount
});
}
}
});
}The emit() Method
Each component has its own dispatcher for custom events:
class Component {
#eventDispatcher = new Dispatcher();
emit(eventName, payload) {
this.#eventDispatcher.dispatch(eventName, payload);
}
// Called by the framework when mounting
#subscribeToEvents(events) {
for (const [event, handler] of Object.entries(events)) {
this.#eventDispatcher.subscribe(event, handler);
}
}
}Putting It Together: createApp()
Now we wire everything into a createApp function:
function createApp({ state, view, reducers }) {
const dispatcher = new Dispatcher();
let vdom = null;
let parentEl = null;
// Re-render after every command
dispatcher.afterEveryCommand(render);
// Register reducers
for (const [command, reducer] of Object.entries(reducers)) {
dispatcher.subscribe(command, (payload) => {
state = reducer(state, payload);
});
}
function emit(command, payload) {
dispatcher.dispatch(command, payload);
}
function render() {
const newVdom = view(state, emit);
if (vdom) {
vdom = patchDOM(vdom, newVdom, parentEl);
} else {
mountDOM(newVdom, parentEl);
vdom = newVdom;
}
}
return {
mount(el) {
parentEl = el;
render();
},
unmount() {
destroyDOM(vdom);
vdom = null;
}
};
}Usage looks like this:
const app = createApp({
state: { count: 0 },
reducers: {
increment: (state) => ({ ...state, count: state.count + 1 }),
decrement: (state) => ({ ...state, count: state.count - 1 }),
},
view: (state, emit) =>
h('div', {}, [
h('p', {}, ['Count: ' + state.count]),
h('button', { on: { click: () => emit('increment') } }, ['+']),
h('button', { on: { click: () => emit('decrement') } }, ['-']),
])
});
app.mount(document.getElementById('root'));That's the entire loop: user clicks button → handler calls emit → dispatcher runs reducer → new state → view called → virtual DOM diffed → real DOM patched.
The Alternative: Signals
Everything we built uses the virtual DOM approach: re-render everything, diff the trees, patch the differences. There's another strategy gaining popularity.
With signals, you don't re-render the world. Instead, the framework tracks which DOM nodes depend on which pieces of state. Change count? Only the text node showing the count updates. No diffing required.
// Signal-based reactivity (simplified)
let currentEffect = null;
function createSignal(initial) {
let value = initial;
const subscribers = new Set();
function read() {
if (currentEffect) {
subscribers.add(currentEffect);
}
return value;
}
function write(newValue) {
value = newValue;
for (const sub of subscribers) {
sub();
}
}
return [read, write];
}
function effect(fn) {
currentEffect = fn;
fn(); // Run once to collect dependencies
currentEffect = null;
}
// Usage
const [count, setCount] = createSignal(0);
effect(() => {
document.getElementById('counter').textContent = count();
});
setCount(5); // Automatically updates the DOMSolid, Vue 3's reactivity system, and Svelte 5's runes all work this way. It's more efficient for fine-grained updates, but requires more framework magic to track dependencies.
Which is better?
Neither. The virtual DOM is easier to reason about: your view function is pure. Same state, same output. Signals are more efficient for surgical updates but require understanding the dependency tracking.
React chose simplicity. Solid chose performance. Both are valid tradeoffs.
What We Built
In about 500 lines of JavaScript, we built a working framework with:
- Virtual DOM: Lightweight JavaScript objects describing UI
- Three node types: Elements, text, and fragments
- h() function: Helper to create virtual nodes
- Mounting: Turning virtual DOM into real DOM
- Destruction: Cleanup with proper event listener removal
- Reconciliation: Efficiently patching only what changed
- Keyed lists: Stable identity across re-renders
- Dispatcher: Managing state updates and triggering renders
- Stateful components: Components with their own state and lifecycle
- Props and events: Parent-child communication
Real frameworks add more: hooks, context, suspense, server rendering, concurrent mode, and a thousand optimizations. But the core loop is what we built.
Next time you write setState or ref.value = x, you'll know what's happening underneath. The framework is just doing what we did, faster and with more edge cases handled.
Want to go deeper? The full source code for this framework, with comments and tests, is available on GitHub. Try extending it with hooks, portals, or your own ideas.