Back to Blog

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

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.

Solution

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+ properties

Every 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:

Virtual Node Types
HELPER FUNCTIONh('button', { class: 'primary' }, ['Click me'])VIRTUAL NODEtype: "element"tag: "button"props: { class: "primary" }children: [{ type: "text", value: "Click me" }]

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 h() Function
h('button', { class: 'primary' }, ['Click me'])
// → Result:
{
type: "element",
tag: "button",
props: { class: "primary" },
children: [{ type: "text", value: "Click me" }]
}

The function takes three arguments:

  1. tag: The element type ('div', 'button', 'span')
  2. props: Attributes and event handlers
  3. 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:

The Mounting Process
mountDOM()textcreateTextNodeelementcreateElementfragmentchildren onlyReal DOM

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:

Attribute Patching
<button>class = "btn"disabled = "false"data-count = "0"

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.

Destroying DOM
Button ComponentClick meonclick attachedlistener1. removeEventListener()2. children.forEach(destroyDOM)3. el.remove()4. delete vdom.el
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

The Problem

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:

Live Reconciliation
  • key="app"Apple
  • key="ban"Banana
  • key="che"Cherry

Keys: The Identity Problem

The 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:

Keys and Identity
key="a"Apple
key="b"Banana
key="c"Cherry

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.

Dispatcher Pattern
Command...Reducerfn(state)State0
// Click buttons to dispatch actions

Three pieces working together:

  1. Commands: Named actions with optional payloads ("increment", "set")
  2. Reducers: Pure functions that compute new state from old state + payload
  3. 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.

Stateful Components
Live Component0
const Counter = defineComponent({
  state() {
    return { count: 0 };
  },
  render() {
    return h('div', {}, [
      h('p', {}, ['Count: ' + this.state.count]),
      h('button', {
        on: { click: () => this.updateState({
          count: this.state.count + 1
        })}
      }, ['+1'])
    ]);
  }
});

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 Down, Events Up
Parent Componentstate.count = 0propseventsChild Componentprops.count = 0
// Click buttons to emit events

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.

Reactivity Models
State ChangeRe-render TreeDiff Old vs NewPatch DOMRe-renders everything, then diffs to find minimal changes

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 DOM

Solid, 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.