Creating Advanced UI Frameworks in JavaScript

Build a modern UI framework from scratch in JavaScript. Covers virtual DOM implementation, diff algorithms, reactive state management, component lifecycle, template compilation, event delegation, batched rendering, hooks system, server-side rendering, and hydration.

JavaScriptadvanced
20 min read

Building a UI framework teaches you how React, Vue, and Svelte work under the hood. This guide implements a virtual DOM, reconciliation algorithm, reactive state system, and component model from scratch.

For the reactive data binding that powers state management, see Data Binding with JS Proxies Complete Guide.

Virtual DOM Implementation

javascriptjavascript
// Virtual DOM nodes are plain objects describing the UI structure
 
function createElement(type, props = {}, ...children) {
  return {
    type,
    props: props || {},
    children: children.flat().map(child =>
      typeof child === "object" ? child : createTextNode(child)
    )
  };
}
 
function createTextNode(text) {
  return {
    type: "TEXT",
    props: {},
    children: [],
    text: String(text)
  };
}
 
// JSX-like helper (h function)
const h = createElement;
 
// Example virtual DOM tree
const vdom = h("div", { className: "app" },
  h("h1", { id: "title" }, "Hello Framework"),
  h("ul", { className: "list" },
    h("li", null, "Item 1"),
    h("li", null, "Item 2"),
    h("li", null, "Item 3")
  ),
  h("button", { onClick: () => console.log("clicked") }, "Click Me")
);
 
// RENDER VIRTUAL DOM TO REAL DOM
function render(vnode) {
  if (vnode.type === "TEXT") {
    return document.createTextNode(vnode.text);
  }
 
  const element = document.createElement(vnode.type);
 
  // Set properties and attributes
  for (const [key, value] of Object.entries(vnode.props)) {
    setProp(element, key, value);
  }
 
  // Render children
  for (const child of vnode.children) {
    element.appendChild(render(child));
  }
 
  return element;
}
 
function setProp(element, key, value) {
  if (key.startsWith("on")) {
    const eventType = key.slice(2).toLowerCase();
    element.addEventListener(eventType, value);
  } else if (key === "className") {
    element.setAttribute("class", value);
  } else if (key === "style" && typeof value === "object") {
    Object.assign(element.style, value);
  } else if (typeof value === "boolean") {
    if (value) element.setAttribute(key, "");
    else element.removeAttribute(key);
  } else {
    element.setAttribute(key, value);
  }
}
 
function removeProp(element, key, value) {
  if (key.startsWith("on")) {
    const eventType = key.slice(2).toLowerCase();
    element.removeEventListener(eventType, value);
  } else if (key === "className") {
    element.removeAttribute("class");
  } else {
    element.removeAttribute(key);
  }
}

Diff and Reconciliation Algorithm

javascriptjavascript
// Compare old and new virtual DOM trees, produce minimal DOM updates
 
const PATCH = {
  CREATE: "CREATE",
  REMOVE: "REMOVE",
  REPLACE: "REPLACE",
  UPDATE: "UPDATE"
};
 
function diff(oldTree, newTree) {
  // New node added
  if (!oldTree) {
    return { type: PATCH.CREATE, newTree };
  }
 
  // Old node removed
  if (!newTree) {
    return { type: PATCH.REMOVE };
  }
 
  // Different types mean full replacement
  if (oldTree.type !== newTree.type) {
    return { type: PATCH.REPLACE, newTree };
  }
 
  // Text nodes: compare text content
  if (oldTree.type === "TEXT") {
    if (oldTree.text !== newTree.text) {
      return { type: PATCH.REPLACE, newTree };
    }
    return null; // No change
  }
 
  // Same type: diff props and children
  const propPatches = diffProps(oldTree.props, newTree.props);
  const childPatches = diffChildren(oldTree.children, newTree.children);
 
  if (!propPatches && childPatches.every(p => p === null)) {
    return null; // No change
  }
 
  return {
    type: PATCH.UPDATE,
    propPatches,
    childPatches
  };
}
 
function diffProps(oldProps, newProps) {
  const patches = [];
 
  // Check for changed or new props
  for (const [key, value] of Object.entries(newProps)) {
    if (oldProps[key] !== value) {
      patches.push({ key, value, action: "set" });
    }
  }
 
  // Check for removed props
  for (const key of Object.keys(oldProps)) {
    if (!(key in newProps)) {
      patches.push({ key, value: oldProps[key], action: "remove" });
    }
  }
 
  return patches.length > 0 ? patches : null;
}
 
function diffChildren(oldChildren, newChildren) {
  const maxLen = Math.max(oldChildren.length, newChildren.length);
  const patches = [];
 
  for (let i = 0; i < maxLen; i++) {
    patches.push(diff(oldChildren[i], newChildren[i]));
  }
 
  return patches;
}
 
// APPLY PATCHES TO REAL DOM
function patch(parent, patches, index = 0) {
  if (!patches) return;
 
  const element = parent.childNodes[index];
 
  switch (patches.type) {
    case PATCH.CREATE: {
      parent.appendChild(render(patches.newTree));
      break;
    }
 
    case PATCH.REMOVE: {
      parent.removeChild(element);
      break;
    }
 
    case PATCH.REPLACE: {
      parent.replaceChild(render(patches.newTree), element);
      break;
    }
 
    case PATCH.UPDATE: {
      // Apply prop changes
      if (patches.propPatches) {
        for (const propPatch of patches.propPatches) {
          if (propPatch.action === "set") {
            setProp(element, propPatch.key, propPatch.value);
          } else {
            removeProp(element, propPatch.key, propPatch.value);
          }
        }
      }
 
      // Apply child patches (in reverse to handle removals correctly)
      for (let i = patches.childPatches.length - 1; i >= 0; i--) {
        patch(element, patches.childPatches[i], i);
      }
      break;
    }
  }
}

Reactive State System

javascriptjavascript
// Proxy-based reactivity that automatically tracks dependencies
 
let activeEffect = null;
const targetMap = new WeakMap();
 
function reactive(target) {
  return new Proxy(target, {
    get(obj, key) {
      track(obj, key);
      const value = obj[key];
      if (typeof value === "object" && value !== null) {
        return reactive(value); // Deep reactivity
      }
      return value;
    },
 
    set(obj, key, value) {
      const oldValue = obj[key];
      obj[key] = value;
      if (oldValue !== value) {
        trigger(obj, key);
      }
      return true;
    }
  });
}
 
function track(target, key) {
  if (!activeEffect) return;
 
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetMap.set(target, depsMap);
  }
 
  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
    depsMap.set(key, deps);
  }
 
  deps.add(activeEffect);
}
 
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
 
  const deps = depsMap.get(key);
  if (!deps) return;
 
  // Copy to avoid infinite loops
  const effects = new Set(deps);
  for (const effect of effects) {
    if (effect !== activeEffect) {
      effect();
    }
  }
}
 
function watchEffect(fn) {
  const effect = () => {
    activeEffect = effect;
    try {
      fn();
    } finally {
      activeEffect = null;
    }
  };
 
  effect(); // Run immediately to collect dependencies
  return effect;
}
 
function computed(getter) {
  let cachedValue;
  let dirty = true;
 
  const effect = () => {
    dirty = true;
  };
 
  return {
    get value() {
      if (dirty) {
        activeEffect = effect;
        try {
          cachedValue = getter();
        } finally {
          activeEffect = null;
        }
        dirty = false;
      }
      track({ _computed: true }, "value");
      return cachedValue;
    }
  };
}
 
// Usage
const state = reactive({ count: 0, name: "World" });
 
watchEffect(() => {
  console.log(`Count is: ${state.count}`);
});
// "Count is: 0" (initial run)
 
state.count++; // "Count is: 1" (automatic re-run)
state.count++; // "Count is: 2"

Component Model with Lifecycle

javascriptjavascript
// Component base class with lifecycle hooks and rendering
 
class Component {
  static _currentInstance = null;
 
  constructor(props = {}) {
    this.props = props;
    this.state = reactive(this.setup ? this.setup(props) : {});
    this._vdom = null;
    this._element = null;
    this._mounted = false;
    this._effects = [];
    this._cleanups = [];
  }
 
  // Override in subclass
  render() {
    return h("div", null, "Empty component");
  }
 
  // LIFECYCLE HOOKS
  onMounted() {}
  onUpdated() {}
  onUnmounted() {}
 
  // STATE MANAGEMENT
  setState(updates) {
    if (typeof updates === "function") {
      updates = updates(this.state);
    }
    Object.assign(this.state, updates);
    // Reactivity triggers re-render automatically
  }
 
  // MOUNT TO DOM
  mount(container) {
    Component._currentInstance = this;
 
    // Setup reactive rendering
    watchEffect(() => {
      const newVdom = this.render();
 
      if (this._vdom) {
        // Update
        const patches = diff(this._vdom, newVdom);
        if (patches) {
          patch(container, patches, 0);
        }
        this._vdom = newVdom;
        if (this._mounted) this.onUpdated();
      } else {
        // Initial render
        this._vdom = newVdom;
        this._element = render(newVdom);
        container.appendChild(this._element);
        this._mounted = true;
        this.onMounted();
      }
    });
 
    Component._currentInstance = null;
  }
 
  unmount() {
    // Run cleanup functions
    for (const cleanup of this._cleanups) {
      cleanup();
    }
    this._cleanups = [];
 
    if (this._element && this._element.parentNode) {
      this._element.parentNode.removeChild(this._element);
    }
 
    this._mounted = false;
    this.onUnmounted();
  }
}
 
// EXAMPLE COMPONENT
class Counter extends Component {
  setup() {
    return { count: 0 };
  }
 
  increment() {
    this.state.count++;
  }
 
  render() {
    return h("div", { className: "counter" },
      h("p", null, `Count: ${this.state.count}`),
      h("button", {
        onClick: () => this.increment()
      }, "Increment")
    );
  }
 
  onMounted() {
    console.log("Counter mounted");
  }
 
  onUpdated() {
    console.log(`Counter updated: ${this.state.count}`);
  }
}
 
// Mounting (in browser context):
// const app = new Counter();
// app.mount(document.getElementById("root"));

Hooks System

javascriptjavascript
// React-style hooks for functional components
 
const hookStates = new WeakMap();
let currentComponent = null;
let hookIndex = 0;
 
function getHookState() {
  if (!hookStates.has(currentComponent)) {
    hookStates.set(currentComponent, []);
  }
  return hookStates.get(currentComponent);
}
 
function useState(initialValue) {
  const hooks = getHookState();
  const idx = hookIndex++;
 
  if (hooks[idx] === undefined) {
    hooks[idx] = typeof initialValue === "function" ? initialValue() : initialValue;
  }
 
  const setState = (newValue) => {
    const current = hooks[idx];
    const next = typeof newValue === "function" ? newValue(current) : newValue;
 
    if (!Object.is(current, next)) {
      hooks[idx] = next;
      scheduleRerender(currentComponent);
    }
  };
 
  return [hooks[idx], setState];
}
 
function useEffect(callback, deps) {
  const hooks = getHookState();
  const idx = hookIndex++;
 
  const prevDeps = hooks[idx]?.deps;
  const hasChanged = !prevDeps || deps === undefined ||
    deps.some((dep, i) => !Object.is(dep, prevDeps[i]));
 
  if (hasChanged) {
    // Cleanup previous effect
    if (hooks[idx]?.cleanup) {
      hooks[idx].cleanup();
    }
 
    // Schedule effect after render
    queueMicrotask(() => {
      const cleanup = callback();
      hooks[idx] = { deps, cleanup: typeof cleanup === "function" ? cleanup : null };
    });
  }
 
  if (!hooks[idx]) {
    hooks[idx] = { deps, cleanup: null };
  }
}
 
function useMemo(factory, deps) {
  const hooks = getHookState();
  const idx = hookIndex++;
 
  const prevDeps = hooks[idx]?.deps;
  const hasChanged = !prevDeps || deps.some((dep, i) => !Object.is(dep, prevDeps[i]));
 
  if (hasChanged) {
    hooks[idx] = { value: factory(), deps };
  }
 
  return hooks[idx].value;
}
 
function useRef(initialValue) {
  const hooks = getHookState();
  const idx = hookIndex++;
 
  if (hooks[idx] === undefined) {
    hooks[idx] = { current: initialValue };
  }
 
  return hooks[idx];
}
 
// BATCHED RENDERING
const renderQueue = new Set();
let renderScheduled = false;
 
function scheduleRerender(component) {
  renderQueue.add(component);
 
  if (!renderScheduled) {
    renderScheduled = true;
    queueMicrotask(() => {
      renderScheduled = false;
      for (const comp of renderQueue) {
        comp.rerender();
      }
      renderQueue.clear();
    });
  }
}
Framework ConceptOur ImplementationReact EquivalentVue Equivalent
Virtual nodecreateElement(type, props, ...children)React.createElement()h() / template compiler
Reconciliationdiff() + patch()Fiber reconcilerpatchVNode()
Statereactive() via ProxyuseState / setStatereactive() / ref()
Side effectswatchEffect()useEffect()watchEffect()
Computedcomputed(getter)useMemo()computed()
ComponentClass with render()Function or ClassSFC / Options API
Rune AI

Rune AI

Key Insights

  • Virtual DOM nodes are plain objects describing UI structure; the render function converts them to real DOM elements: This separation enables diffing, batching, and platform-agnostic rendering
  • The diff algorithm compares old and new virtual trees to produce minimal DOM patches: It checks node type, props, and children recursively, avoiding unnecessary DOM operations
  • Proxy-based reactivity auto-tracks dependencies and triggers re-renders when tracked properties change: This eliminates manual shouldComponentUpdate logic and enables fine-grained updates
  • Component lifecycle hooks (mounted, updated, unmounted) provide insertion points for side effects and cleanup: Effects scheduled in watchEffect run automatically when their dependencies change
  • Hooks store state in arrays indexed by call order, which is why they must be called unconditionally and in the same order: This constraint enables function components to have persistent state without classes
RunePowered by Rune AI

Frequently Asked Questions

Why use a virtual DOM instead of direct DOM manipulation?

The virtual DOM provides a declarative programming model: you describe what the UI should look like, and the framework figures out the minimal changes needed. Direct DOM manipulation requires you to manually track what changed and update specific nodes. The virtual DOM also enables batched updates (multiple state changes produce a single DOM update), server-side rendering (virtual DOM can render to strings), and platform-agnostic rendering (the same virtual DOM can target different renderers). The trade-off is memory overhead for maintaining the virtual tree and CPU overhead for diffing.

How does key-based reconciliation work?

Keys help the diff algorithm identify which children moved, were added, or were removed in a list. Without keys, the algorithm matches children by index, which causes unnecessary DOM operations when items are reordered. With keys, the algorithm builds a map of old children by key, then for each new child, looks up the matching old child to reuse its DOM node. This preserves component state and DOM element identity during reorders. Keys should be stable, unique identifiers (database IDs, not array indices).

What makes Proxy-based reactivity better than setState?

Proxy-based reactivity automatically detects which state properties a component uses and re-renders only when those specific properties change. With setState, you must manually decide when to update and the framework re-renders the entire component subtree. Proxy reactivity also enables fine-grained updates: if a component uses `state.a` but not `state.b`, changing `state.b` does not trigger a re-render. The trade-off is that Proxy has a runtime cost per property access and does not work with primitives directly.

How do hooks manage state without classes?

Hooks use the call order within a component function to associate state with specific hook calls. Each component maintains an array of hook states. On each render, the framework resets a counter to zero and increments it for each hook call. `useState` at index 0 always refers to the same state slot. This is why hooks cannot be called conditionally (it would shift the indices). The hook state array is stored in a WeakMap keyed by the component instance, allowing automatic cleanup when the component is garbage collected.

Conclusion

Building a UI framework reveals the engineering behind declarative rendering. Virtual DOM with reconciliation, Proxy-based reactivity, and hooks-style state management form the core of modern frameworks. For the reactive proxy patterns underlying state systems, see Data Binding with JS Proxies Complete Guide. For Web Worker offloading of rendering work, explore Advanced Web Workers for High Performance JS.