DEV Community

Cover image for How to Build a JavaScript Framework
Patrick G
Patrick G

Posted on

How to Build a JavaScript Framework

"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>
Enter fullscreen mode Exit fullscreen mode

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!"],
};
Enter fullscreen mode Exit fullscreen mode

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.",
      ],
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

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!"],
};
Enter fullscreen mode Exit fullscreen mode

First we create the actual element:

const element = document.createElement(
  description.tag,
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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"],
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

It can build an element and dress it. Onto the children.

Adding text children

The simplest child is a string:

children: ["Hello World!"]
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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!"],
    },
  ],
};
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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),
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

Give it a mount point in your HTML:

<div id="app"></div>
Enter fullscreen mode Exit fullscreen mode

and you can drop your UI onto the page:

render(
  ourUI,
  document.querySelector("#app"),
);
Enter fullscreen mode Exit fullscreen mode

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!"],
};
Enter fullscreen mode Exit fullscreen mode

A little helper takes the sting out:

const h = (
  tag,
  {
    attributes = {},
    children = [],
  } = {},
) => ({
  tag,
  attributes,
  children,
});
Enter fullscreen mode Exit fullscreen mode

Now the same heading is a one-liner:

const heading = h("h1", {
  children: ["Hello World!"],
});
Enter fullscreen mode Exit fullscreen mode

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");
  },
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Then a function that describes the UI in terms of that data:

const view = () =>
  h("button", {
    children: [`Count: ${count}`],
  });
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

a function describing the current UI:

const view = () => {
  return h(/* ... */);
};
Enter fullscreen mode Exit fullscreen mode

an update function:

const update = () => {
  const nextNode = construct(view());

  currentNode.replaceWith(nextNode);

  currentNode = nextNode;
};
Enter fullscreen mode Exit fullscreen mode

and one initial render:

currentNode = construct(view());
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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}`],
    });
});
Enter fullscreen mode Exit fullscreen mode

Read it as two nested functions with different jobs. The outer one is setup:

(update) => {
  let count = 5;

  // Return the view function.
}
Enter fullscreen mode Exit fullscreen mode

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 */);
};
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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: [],
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

With that, the component() helper is short:

const component = (setup) => {
  return (props = {}) => ({
    type: COMPONENT,
    setup,
    props,
  });
};
Enter fullscreen mode Exit fullscreen mode

Calling Counter() doesn't run anything or touch the DOM. It just returns a description:

{
  type: COMPONENT,
  setup,
  props: {},
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

The first time a description arrives, setup runs and returns the view:

instance.view = setup(update, props);
Enter fullscreen mode Exit fullscreen mode

The first render follows:

instance.currentNode = construct(
  instance.view(),
);
Enter fullscreen mode Exit fullscreen mode

Then we remember the live instance under that description object:

componentInstances.set(
  description,
  instance,
);
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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),
  );
};
Enter fullscreen mode Exit fullscreen mode

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}`],
    });
});
Enter fullscreen mode Exit fullscreen mode

and render it:

render(
  Counter(),
  document.querySelector("#app"),
);
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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}`,
        ],
      });
  },
);
Enter fullscreen mode Exit fullscreen mode

Which lets you stamp out counters that behave differently:

const app = h("main", {
  children: [
    Counter({
      initialCount: 5,
      step: 1,
    }),

    Counter({
      initialCount: 100,
      step: 10,
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

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,
      ],
    });
});
Enter fullscreen mode Exit fullscreen mode

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],
  });
Enter fullscreen mode Exit fullscreen mode

This creates a new child on every parent render:

return () =>
  h("main", {
    children: [Counter()],
  });
Enter fullscreen mode Exit fullscreen mode

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,
    ],
  });
Enter fullscreen mode Exit fullscreen mode

Drop a counter inside a card and it just works:

const app = h("main", {
  children: [
    Card({
      title: "My Counter",
      children: [
        Counter({
          initialCount: 5,
        }),
      ],
    }),
  ],
});
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

and updating is something you say out loud:

count += 1;
update();
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

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!"],
});
Enter fullscreen mode Exit fullscreen mode

Turn the data into DOM with a recursive function:

const node = construct(ui);
Enter fullscreen mode Exit fullscreen mode

Give yourself a way to update the data. Change ordinary state, then ask the UI to catch up:

count += 1;
update();
Enter fullscreen mode Exit fullscreen mode

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.
  };
});
Enter fullscreen mode Exit fullscreen mode

Keep an instance alive by keeping and reusing its component description:

const counter = Counter();

return () =>
  h("main", {
    children: [counter],
  });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
frank_signorini profile image
Frank

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.