Zum Hauptinhalt springen
Intelligence View
⚡ tsecurity.de Intelligence

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>

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.

Constructing components

We teach construct() to recognize the new shape:

if (description.type === COMPONENT) {
  return constructComponent(description);
}

Then we move that repeated bookkeeping into constructComponent(), where it can live once and for all:

const constructComponent = ({
  setup,
  props,
}) => {
  let currentNode;
  let view;

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

    currentNode.replaceWith(nextNode);

    currentNode = nextNode;
  };

  view = setup(update, props);

  currentNode = construct(view());

  return currentNode;
};

If that reads familiar, it should, because it's the manual version relocated. Setup runs first and returns a view:

view = setup(update, props);

the first render happens:

currentNode = construct(view());

and later, when the component calls its own update(), the framework rebuilds and swaps the root node. The developer never has to think about currentNode again. That chore belongs 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");

// 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 = ({
  setup,
  props,
}) => {
  let currentNode; // this instance's node on the page
  let view;

  // Re-run the view and swap the old node for the new one.
  const update = () => {
    const nextNode = construct(view());
    currentNode.replaceWith(nextNode);
    currentNode = nextNode;
  };

  view = setup(update, props); // runs once; sets up private state
  currentNode = construct(view()); // first render

  return 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's teaching code, and it skips the hard, unglamorous stuff a real one has to sweat: cleanup, fragments, boolean attributes, namespaces, keyed lists, efficient DOM patching. Those matter enormously in practice, but pile them in here and they'd bury the one idea worth taking away. A description goes into construct(), and a DOM node comes out.

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. This line runs exactly once per instance:

let count = 5;

The view keeps reaching that variable through the closure, and that closure is the component's state. Nothing else is holding it.

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 one that gets constructed gets its own everything: its own setup call, its own closure, its own state, its own view, its own update(), its own DOM node. They stay out of each other's way because nothing is shared between them.

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. 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 view reruns, the root gets replaced. A serious framework does far more. It detects dependencies automatically, batches updates, schedules rendering, diffs old and new descriptions, touches individual DOM properties instead of whole subtrees, preserves child instances across renders, 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.
  };
});

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 description again when the data changes. Everything else is just framework design.

Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten How to Build a JavaScript Framework

Thematisch verwandte Begriffe: Build, JavaScript, Framework · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Zum Aktualisieren ziehen
ZERO-DAY CVE-2026-61591 | djust provides Phoenix LiveView-style reactive server-side rendering for…
Advisory →
TTS Reader • tsecurity.de Voice
tsecurity.de Icon
tsecurity.de App
Offline-Lesen, Eilmeldungen & 0ms Ladezeit

Installiere tsecurity.de direkt auf deinen Home-Bildschirm für das ultimative Vollbild-Magazinerlebnis ohne Browser-Leisten.

Nächster Beitrag
Themen-Radar & Intelligence Matrix
Echtzeit-Taxonomie nach Angriffsvektoren & Plattformen
Community Radar & Live Chat
Sentinel Bot online • Live-Stream
Dein Cluster: Security Explorer
Match:
lädt…
Verbindung zum Community-Stream wird aufgebaut...
Aktivitäten deiner Analysten
lädt…
Neues Thema oder Eilmeldung einreichen

Reiche interessante Links, Zero-Days oder Debatten ein. Die Community entscheidet per Upvote über die Veröffentlichung.

Heiß diskutierte Einreichungen
🔖 Gespeicherte Artikel
📂 Keine gespeicherten Artikel vorhanden.
News ⏱️ 3 Min vor 10 Min
Artikeldaten werden geladen...

↗ Original-Quelle