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.
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
// 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
// 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
// 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
// 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
// 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 Concept | Our Implementation | React Equivalent | Vue Equivalent |
|---|---|---|---|
| Virtual node | createElement(type, props, ...children) | React.createElement() | h() / template compiler |
| Reconciliation | diff() + patch() | Fiber reconciler | patchVNode() |
| State | reactive() via Proxy | useState / setState | reactive() / ref() |
| Side effects | watchEffect() | useEffect() | watchEffect() |
| Computed | computed(getter) | useMemo() | computed() |
| Component | Class with render() | Function or Class | SFC / Options API |
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
Frequently Asked Questions
Why use a virtual DOM instead of direct DOM manipulation?
How does key-based reconciliation work?
What makes Proxy-based reactivity better than setState?
How do hooks manage state without classes?
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.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.