"Framework" is one of those words that sounds heavier than it is. Say it out loud and a whole vocabulary comes tumbling after: components, state, rendering, reactivity, reconciliation, virtual DOM, lifecycle hooks, compilers. It's enough to make you assume there's some deep magic happening that only a handful of people at Meta actually understand.
There's no magic. Strip the jargon away and a UI framework is a small idea wearing a big coat. By the end of this post we'll have built a working one, and it'll fit comfortably in your head. Let me show you the idea. We'll name the parts as we go.
Step 1: Bring your UI into JavaScript
Normally the browser thinks about UI as HTML:
<h1>Hello World!</h1>
We want to describe that same UI as plain JavaScript data instead. Why? Because once your interface is data, you can generate it, transform it, and diff it with ordinary code, no template language required.
There are a dozen ways to represent it. Objects, functions, arrays, JSX, template strings, whatever a compiler spits out. We'll use an object, because objects are easy to read and even easier to poke at in a console.
const ourUI = {
tag: "h1",
attributes: {},
children: ["Hello World!"],
};
Three fields, and each one maps to something the browser cares about. tag is the kind of element to create, here an "h1". attributes is whatever gets attached to it, nothing in this case. children is what goes inside, some text.
Nesting works the way you'd hope, since a child can be another one of these objects:
const ourUI = {
tag: "main",
attributes: {
id: "welcome",
},
children: [
{
tag: "h1",
attributes: {},
children: ["Hello World!"],
},
{
tag: "p",
attributes: {},
children: [
"We are building a framework.",
],
},
],
};
Notice that nothing has actually appeared on the page. This is still an object sitting in memory, a description of a UI rather than a UI. That distinction is the whole game, so hold onto it. The next step is turning the description into something the browser can render.
Step 2: Turn the data into UI
Start with the smallest possible case. Say we're handed this:
const description = {
tag: "h1",
attributes: {},
children: ["Hello World!"],
};
First we create the actual element:
const element = document.createElement(
description.tag,
);
Since description.tag is "h1", that's the same as writing document.createElement("h1"). Either way we're left with an empty <h1> in hand.
Now for the contents. Our only child is a string, and the browser represents text inside an element as a text node, so we make one and stick it inside:
const text = document.createTextNode(
"Hello World!",
);
element.appendChild(text);
We've reconstructed <h1>Hello World!</h1> from scratch. The move we just performed, description in and DOM node out, is the one operation this entire framework is built around. Let's wrap it in a function so we can reuse it.
Constructing one element
We'll call it construct(). It takes a UI description and hands back a real DOM node.
const construct = (description) => {
const element = document.createElement(
description.tag,
);
return element;
};
Right now it only builds the shell. Given { tag: "h1", attributes: {}, children: [] }, it returns an empty <h1> and nothing more. Let's teach it the rest, starting with attributes.
Adding attributes
Descriptions can carry attributes like these:
{
tag: "button",
attributes: {
id: "save-button",
class: "primary",
},
children: ["Save"],
}
Object.entries() flattens that object into name and value pairs, [["id", "save-button"], ["class", "primary"]], which is exactly the shape a loop wants:
for (
const [name, value]
of Object.entries(
description.attributes ?? {},
)
) {
element.setAttribute(name, value);
}
Each pair goes straight to setAttribute(), and the constructor grows accordingly:
const construct = (description) => {
const element = document.createElement(
description.tag,
);
for (
const [name, value]
of Object.entries(
description.attributes ?? {},
)
) {
element.setAttribute(name, value);
}
return element;
};
It can build an element and dress it. Onto the children.
Adding text children
The simplest child is a string:
children: ["Hello World!"]
We already know the drill. Turn it into a text node and append it. Folded into the constructor, that's:
const construct = (description) => {
const element = document.createElement(
description.tag,
);
for (
const [name, value]
of Object.entries(
description.attributes ?? {},
)
) {
element.setAttribute(name, value);
}
for (
const child
of description.children ?? []
) {
const textNode =
document.createTextNode(child);
element.appendChild(textNode);
}
return element;
};
Good enough for a lone heading. But it falls apart the moment a child isn't a string:
const ourUI = {
tag: "main",
attributes: {},
children: [
{
tag: "h1",
attributes: {},
children: ["Hello World!"],
},
],
};
That child is an object, another full description, and document.createTextNode() has no idea what to do with it. We need to build the child the same way we build its parent.
Constructing children recursively
We already have a function that turns a description into a DOM node, and it's construct() itself. So when a child turns out to be another description, we hand it back to construct() and let the function call itself.
That's recursion, and it fits the problem cleanly. A child can have children, and those can have children of their own, all the way down. The function keeps descending until it hits plain text and stops.
const construct = (description) => {
if (
typeof description === "string" ||
typeof description === "number"
) {
return document.createTextNode(
String(description),
);
}
const element = document.createElement(
description.tag,
);
for (
const [name, value]
of Object.entries(
description.attributes ?? {},
)
) {
element.setAttribute(name, value);
}
for (
const child
of description.children ?? []
) {
const childNode = construct(child);
element.appendChild(childNode);
}
return element;
};
The function now handles two cases.
When the description is text, we've hit the bottom of the tree:
if (
typeof description === "string" ||
typeof description === "number"
) {
return document.createTextNode(
String(description),
);
}
A string becomes a text node. A number like 5 becomes the text "5". Either way we return and unwind.
Otherwise it's an element, so we do the full routine. Create it, apply its attributes, and construct each child, which is where the function calls back into itself:
const element = document.createElement(
description.tag,
);
for (
const [name, value]
of Object.entries(
description.attributes ?? {},
)
) {
element.setAttribute(name, value);
}
for (
const child
of description.children ?? []
) {
element.appendChild(construct(child));
}
return element;
That's the core of the framework. Mostly one recursive function. Follow this part and the rest is detail.
Rendering the result
construct() builds a DOM tree but never puts it anywhere. For that we need a tiny render():
const render = (
description,
container,
) => {
const element = construct(description);
container.appendChild(element);
};
Give it a mount point in your HTML:
<div id="app"></div>
and you can drop your UI onto the page:
render(
ourUI,
document.querySelector("#app"),
);
The whole pipeline reads top to bottom. ourUI goes into construct(), a DOM tree comes out, and it gets appended to #app. That is a working UI framework. It's a small one, but it does the real job of taking JavaScript data and producing DOM.
Making descriptions easier to write
Spelling out the full object every single time gets old fast:
const heading = {
tag: "h1",
attributes: {},
children: ["Hello World!"],
};
A little helper takes the sting out:
const h = (
tag,
{
attributes = {},
children = [],
} = {},
) => ({
tag,
attributes,
children,
});
Now the same heading is a one-liner:
const heading = h("h1", {
children: ["Hello World!"],
});
But h() doesn't change anything underneath. Same { tag, attributes, children } object as before. Just a friendlier way to type it. That idea is bigger than it looks, because almost every fancy framework syntax you've met is doing the same thing. JSX, template literals, helper functions, whole compilers, they're all just different front doors to one thing: data that describes a UI.
Adding events
Attributes like id and class are strings, and setAttribute() handles them fine. Events are functions, though:
attributes: {
onclick: () => {
console.log("Clicked");
},
}
Hand a function to setAttribute() and you won't get the behavior you want. It'll stringify the function and set a useless attribute. So we branch on a simple rule. If the name starts with on and the value is a function, assign it to the element directly. Otherwise fall back to setAttribute().
for (
const [name, value]
of Object.entries(
description.attributes ?? {},
)
) {
if (
name.startsWith("on") &&
typeof value === "function"
) {
element[name] = value;
} else {
element.setAttribute(name, value);
}
}
Even with events in the mix, the whole constructor stays small:
const construct = (description) => {
if (
typeof description === "string" ||
typeof description === "number"
) {
return document.createTextNode(
String(description),
);
}
const element = document.createElement(
description.tag,
);
for (
const [name, value]
of Object.entries(
description.attributes ?? {},
)
) {
if (
name.startsWith("on") &&
typeof value === "function"
) {
element[name] = value;
} else {
element.setAttribute(name, value);
}
}
for (
const child
of description.children ?? []
) {
element.appendChild(
construct(child),
);
}
return element;
};
This isn't production-grade, to be clear. It ignores plenty of DOM edge cases on purpose so the shape stays visible. What it does comes down to five steps: turn text into text nodes, turn element descriptions into elements, apply attributes, build children recursively, and hand back the finished tree.
Step 3: Give yourself a way to update the data
So far we can render UI, but only the frozen kind. Real interfaces don't hold still. Buttons get clicked, inputs change, a fetch resolves, a timer fires. Some value shifts, and the screen is supposed to keep up.
The pattern for handling that is almost boringly direct:
Store some data.
Describe the UI using that data.
Change the data.
Describe the UI again.
Before we hide any of this behind a component abstraction, let's do it by hand once, so there's no mystery left in it.
A counter with no components
We need some data:
let count = 5;
Then a function that describes the UI in terms of that data:
const view = () =>
h("button", {
children: [`Count: ${count}`],
});
The point is that view() reads count fresh every time it runs. Call it now and it describes a button reading "Count: 5". Bump the count:
count += 1;
and the next call describes "Count: 6" instead. The view isn't a fixed thing. It's a function of the current data. Change the data, call again, get a new description.
Now put the first version on the page and hang onto a reference to it:
let currentNode = construct(view());
document
.querySelector("#app")
.appendChild(currentNode);
We keep currentNode around for one reason. When the data changes, we'll need to know which node to swap out.
Writing update()
The update routine has an obvious job. Run the view again, build the new DOM, replace the old DOM, and remember what's now on screen:
const update = () => {
const nextNode = construct(view());
currentNode.replaceWith(nextNode);
currentNode = nextNode;
};
Wire the button up to call it, and the counter comes alive:
let count = 5; // the state
let currentNode; // whatever button is on the page right now
// Describes the UI for the current count. Reads count fresh on every call.
const view = () =>
h("button", {
attributes: {
onclick: () => {
count += 1; // change the data
update(); // then ask the UI to catch up
},
},
children: [`Count: ${count}`],
});
const update = () => {
const nextNode = construct(view()); // build the new button
currentNode.replaceWith(nextNode); // swap it in for the old one
currentNode = nextNode; // remember it for next time
};
// First render.
currentNode = construct(view());
document
.querySelector("#app")
.appendChild(currentNode);
Every click runs two lines, count += 1 and then update(), and that's the entire loop. Change the data, then ask the UI to catch up. No state object here, no virtual DOM, no compiler. Just change a value, run the view again, replace the old node. That turns out to be enough to build an interactive interface.
Spotting the pattern
Look at what the counter is made of and you'll see four moving parts. There's some state:
let count = 5;
a function describing the current UI:
const view = () => {
return h(/* ... */);
};
an update function:
const update = () => {
const nextNode = construct(view());
currentNode.replaceWith(nextNode);
currentNode = nextNode;
};
and one initial render:
currentNode = construct(view());
You could copy-paste this for every interactive corner of an app. But the DOM bookkeeping, the currentNode juggling and the replace-and-remember dance, would be identical every time:
let currentNode;
const update = () => {
const nextNode = construct(view());
currentNode.replaceWith(nextNode);
currentNode = nextNode;
};
Repetition like that is a signal. When the same shape shows up over and over, it's asking to be packaged, and that package is what we call a component.
Formalizing the pattern
Here's the shape we're aiming for:
const Counter = component((update) => {
let count = 5;
return () =>
h("button", {
attributes: {
onclick: () => {
count += 1;
update();
},
},
children: [`Count: ${count}`],
});
});
Read it as two nested functions with different jobs. The outer one is setup:
(update) => {
let count = 5;
// Return the view function.
}
It runs exactly once, when the component is born, which is why count gets initialized a single time. Setup then returns a second function, the view:
return () => {
return h(/* current UI */);
};
This one runs as often as you like. And because it was defined inside setup, it can still reach count every time it runs, long after setup has finished. That's a closure, and it's doing real work here. It's what lets each component keep its own private state alive between renders.
So the component lifecycle is just:
Run setup once.
Create private data.
Return a view function.
Re-run the view whenever update() is called.
The wrapper isn't inventing anything. It's the by-hand counter from a minute ago with a name attached.
Representing a component
A regular element description looks like this:
{
tag: "button",
attributes: {},
children: [],
}
Components need a description of their own, and we need a reliable way to tell the two apart. A Symbol works well, because it's a value that can't be confused with anything else:
const COMPONENT = Symbol("component");
With that, the component() helper is short:
const component = (setup) => {
return (props = {}) => ({
type: COMPONENT,
setup,
props,
});
};
Calling Counter() doesn't run anything or touch the DOM. It just returns a description:
{
type: COMPONENT,
setup,
props: {},
}
The component stays as data in the tree until something decides to build it, which keeps it consistent with everything else we've made.
That description object can do one more job for us: it can be the component's identity. If the exact same object appears in the tree again, we'll treat it as the same live component. A fresh call to Counter() makes a fresh object, and therefore a fresh instance.
That gives us a tiny identity rule without inventing IDs or keys yet:
const first = Counter();
const second = Counter();
first === first; // same instance description
first === second; // false: two separate instances
Each description represents one mounted component instance. To render two counters, call Counter() twice.
Constructing components
We teach construct() to recognize the new shape:
if (description.type === COMPONENT) {
return constructComponent(description);
}
Then we move the repeated bookkeeping into constructComponent(), where it can live once and for all.
We'll also keep a WeakMap from component descriptions to their live instances:
const componentInstances = new WeakMap();
A WeakMap is useful here because its keys are objects, exactly what our component descriptions are. When the same description comes through construct() again, we can hand back the component that already exists instead of running setup a second time.
const constructComponent = (
description,
) => {
const existing =
componentInstances.get(description);
if (existing) {
return existing.currentNode;
}
const {
setup,
props,
} = description;
const instance = {
currentNode: null,
view: null,
};
const update = () => {
const nextNode = construct(
instance.view(),
);
instance.currentNode.replaceWith(
nextNode,
);
instance.currentNode = nextNode;
};
instance.view = setup(update, props);
instance.currentNode = construct(
instance.view(),
);
componentInstances.set(
description,
instance,
);
return instance.currentNode;
};
The first time a description arrives, setup runs and returns the view:
instance.view = setup(update, props);
The first render follows:
instance.currentNode = construct(
instance.view(),
);
Then we remember the live instance under that description object:
componentInstances.set(
description,
instance,
);
Later, when either the component or one of its parents renders the same description again, we take the short path:
const existing =
componentInstances.get(description);
if (existing) {
return existing.currentNode;
}
That detail matters. An update calls only instance.view(), the function returned by setup. It does not call setup() again, so the closure holding the component's private state stays alive.
The developer never has to think about currentNode, instance storage, or the replace-and-remember dance again. Those chores belong to the framework now.
The whole thing
Here's the complete implementation, top to bottom:
// Tags a description as a component so construct() can spot it later.
const COMPONENT = Symbol("component");
// Keeps each component description connected to its live instance.
const componentInstances = new WeakMap();
// Sugar for building a plain element description.
const h = (
tag,
{
attributes = {},
children = [],
} = {},
) => ({
tag,
attributes,
children,
});
// Wraps a setup function. Calling the result returns a component description,
// not DOM — nothing runs until construct() gets to it.
const component = (setup) => {
return (props = {}) => ({
type: COMPONENT,
setup,
props,
});
};
// Builds one live component instance and owns its update loop.
const constructComponent = (
description,
) => {
// The same description means the same component instance.
const existing =
componentInstances.get(description);
if (existing) {
return existing.currentNode;
}
const {
setup,
props,
} = description;
const instance = {
currentNode: null,
view: null,
};
// Re-run only the returned view and swap this component's root node.
const update = () => {
const nextNode = construct(
instance.view(),
);
instance.currentNode.replaceWith(
nextNode,
);
instance.currentNode = nextNode;
};
// Setup runs once for this description and returns the reusable view.
instance.view = setup(update, props);
// First render.
instance.currentNode = construct(
instance.view(),
);
componentInstances.set(
description,
instance,
);
return instance.currentNode;
};
// The heart of it: description in, DOM node out.
const construct = (description) => {
// Base case: text and numbers become text nodes.
if (
typeof description === "string" ||
typeof description === "number"
) {
return document.createTextNode(
String(description),
);
}
// Hand components off to their own builder.
if (description.type === COMPONENT) {
return constructComponent(description);
}
// Otherwise it's an element description.
const element = document.createElement(
description.tag,
);
for (
const [name, value]
of Object.entries(
description.attributes ?? {},
)
) {
// onclick, oninput, etc. are functions — assign them directly.
if (
name.startsWith("on") &&
typeof value === "function"
) {
element[name] = value;
} else {
element.setAttribute(name, value);
}
}
// Build each child the same way, recursively.
for (
const child
of description.children ?? []
) {
element.appendChild(
construct(child),
);
}
return element;
};
// Construct a description and mount it into a container.
const render = (
description,
container,
) => {
container.replaceChildren(
construct(description),
);
};
That's the entire framework. It now does one small but important piece of identity handling: when the same component description object appears again, the existing live instance is reused, so its setup and closure survive.
It's still teaching code, and it skips the hard, unglamorous stuff a real framework has to sweat: cleanup, fragments, boolean attributes, namespaces, general reconciliation, keyed lists, efficient DOM patching, scheduling, and error handling. Those matter enormously in practice, but pile them in here and they'd bury the idea worth taking away. A description goes into construct(), and a DOM node comes out. A component description that comes back is recognized and kept alive.
Rendering the counter
Define the component:
const Counter = component((update) => {
let count = 5;
return () =>
h("button", {
attributes: {
onclick: () => {
count += 1;
update();
},
},
children: [`Count: ${count}`],
});
});
and render it:
render(
Counter(),
document.querySelector("#app"),
);
On first construction, setup runs, count starts at 5, setup returns the view, the view returns a button description, construct() turns it into DOM, and the DOM lands on the page. On each click, count ticks up, the component calls update(), the view runs again and describes a button reading "Count: 6", construct() builds a fresh button, and it replaces the old one.
Setup, importantly, does not run a second time for that component description. This line runs exactly once:
let count = 5;
The view keeps reaching that variable through the closure, and that closure is the component's state. The instance map keeps the description connected to the latest DOM node, so calling construct() with that description again does not reset the closure.
Adding props
Setup gets a second argument, props, so components can be configured from outside:
const Counter = component(
(update, props) => {
let count =
props.initialCount ?? 0;
const step =
props.step ?? 1;
return () =>
h("button", {
attributes: {
onclick: () => {
count += step;
update();
},
},
children: [
`Count: ${count}`,
],
});
},
);
Which lets you stamp out counters that behave differently:
const app = h("main", {
children: [
Counter({
initialCount: 5,
step: 1,
}),
Counter({
initialCount: 100,
step: 10,
}),
],
});
Every Counter() call produces a separate description, and every description that gets constructed gets its own everything: its own setup call, its own closure, its own state, its own view, its own update(), and its own DOM node. They stay out of each other's way because nothing is shared between them.
Keeping child components alive
The instance map gives us a useful rule: reuse the same component description, and you reuse the same component.
That matters when one component renders another. Imagine a dashboard whose own button changes the surrounding layout while a counter inside it keeps counting:
const Dashboard = component((update) => {
let compact = false;
// Created during setup, so this description is made once.
const counter = Counter({
initialCount: 5,
});
return () =>
h("main", {
attributes: {
class: compact
? "compact"
: "comfortable",
},
children: [
h("button", {
attributes: {
onclick: () => {
compact = !compact;
update();
},
},
children: [
compact
? "Use comfortable layout"
: "Use compact layout",
],
}),
counter,
],
});
});
Dashboard's setup runs once. During setup it calls Counter() once and holds onto the resulting description in counter.
When the dashboard updates, only its returned view runs again. The new <main> is constructed, but it contains the same counter object. When construct() reaches it, the WeakMap finds the existing counter instance and returns its current DOM node. The node moves into the rebuilt parent, while the counter's setup, closure, and current count stay intact.
The placement of Counter() is what makes the difference. This preserves the child:
const child = Counter();
return () =>
h("main", {
children: [child],
});
This creates a new child on every parent render:
return () =>
h("main", {
children: [Counter()],
});
For our tiny framework, object identity is the key. Real frameworks can recover identity from component type, position, explicit keys, or compiler-generated bookkeeping. We're using the smallest rule that lets the idea stay visible.
Composition
Because our UI is plain data, you compose it with plain functions. There's no special API to learn, just JavaScript:
const Card = ({
title,
children,
}) =>
h("section", {
attributes: {
class: "card",
},
children: [
h("h2", {
children: [title],
}),
...children,
],
});
Drop a counter inside a card and it just works:
const app = h("main", {
children: [
Card({
title: "My Counter",
children: [
Counter({
initialCount: 5,
}),
],
}),
],
});
Elements can hold components, components can return elements, and functions can produce either one. Stateful child descriptions can be created once during setup and reused wherever the returned view places them. That freedom to mix is where a real composition model starts, and we barely had to do anything to get it.
Where React, Vue, and Svelte actually differ
We've now arrived at the pattern every UI framework is built to package:
Store some state.
Describe UI using that state.
Change the state.
Update the UI.
Once you see past the syntax, the differences between React, Vue, Svelte and the rest come down to two questions. How do they package this pattern, and how do they figure out when and what to update?
Our version packages it with a closure and an explicit call. State is a variable:
let count = 5;
and updating is something you say out loud:
count += 1;
update();
We respond by re-running the view and replacing the component's root node. It's about as blunt as an approach can get, and it works.
The big frameworks make different bets. React asks your component to return a description of the current UI, routes state changes through setter functions, and decides on its own when to re-run you and how to reconcile the result. Vue wraps state in a reactive system that tracks which bits of UI depend on which values, then updates only what's affected when those values change. Svelte pushes the work earlier, into a compile step, analyzing your component and generating code that makes surgical updates when state changes.
The syntax differs and the strategies differ, but the questions underneath don't move:
What data changed?
Which UI depends on it?
When should the UI update?
How should the DOM change?
Our answers are deliberately naive. The developer changes a variable, the developer calls update(), the returned view reruns, and the component's root gets rebuilt. If that view reuses a child component description, we preserve the child's live instance by object identity. Everything else is blunt.
A serious framework does far more. It detects dependencies automatically, batches updates, schedules rendering, compares old and new descriptions, touches individual DOM properties instead of whole subtrees, matches children by type, position, or key, compiles targeted updates, and cleans up what's removed. All of that is sophistication layered on top of the same loop. The pattern was doing the load-bearing work the whole time, not the syntax.
The recipe, start to finish
Bring the UI into JavaScript. Represent the interface as data:
const ui = h("h1", {
children: ["Hello World!"],
});
Turn the data into DOM with a recursive function:
const node = construct(ui);
Give yourself a way to update the data. Change ordinary state, then ask the UI to catch up:
count += 1;
update();
Package the pattern so you're not rewriting the DOM bookkeeping every time:
const Counter = component((update) => {
let count = 5;
return () => {
// Describe the current UI.
};
});
Keep an instance alive by keeping and reusing its component description:
const counter = Counter();
return () =>
h("main", {
children: [counter],
});
Profit. Or the less triumphant version: spend the next several years optimizing updates, chasing edge cases, maintaining compiler tooling, and arguing about state-management APIs on the internet. That frontier really is endless. But the loop at the center never gets more complicated than this:
Data
↓
UI description
↓
DOM
↓
User interaction
↓
Updated data
↓
Updated UI description
↓
Updated DOM
Describe the interface as data. Turn the data into UI. Run the returned view again when the data changes. Reuse a component description when you want that instance to survive. Everything else is framework design.
Top comments (1)
I'm curious about the author's approach to handling DOM updates in their framework, how do they plan to optimize performance? I'd love to swap ideas on this, been experimenting with similar concepts myself.