§ 1 the reframe
The comfortable lie: JSX is not markup
The confusion almost always starts in the same place: JSX looks like HTML, so it feels like the function returns markup, and then it's mysterious how a JavaScript class “handles markup.” The fix is to stop believing the syntax.
Browsers cannot read JSX. Before your code ever runs, a build step (Babel, esbuild, the TypeScript compiler) rewrites every JSX tag into an ordinary function call. The angle brackets are sugar. Here is the literal transformation applied to your component's body:
// YOU WRITE THIS (JSX): <host> <h1>Hello {message}</h1> </host> // THE COMPILER REWRITES IT TO THIS (plain JS function calls): h("host", null, h("h1", null, "Hello ", message)) // ...and h(...) just builds and returns a plain object: { type: "host", props: {}, children: [ { type: "h1", props: {}, children: ["Hello ", "…"] } ] }
That h function (Atomico's “hyperscript” pragma, supplied by its jsx-runtime) does nothing dramatic. It takes a tag name, a props object, and children, and returns a plain JavaScript object describing them. No element is created. Nothing is attached to the page. It's just a value — a little tree of objects.
So the real question — “how does the class handle the JSX?” — becomes far less spooky once restated: how does the class turn a tree of plain objects into shadow-DOM nodes? That's a problem with an ordinary, inspectable answer, and the rest of this page is that answer.
§ 2 see it for yourself
What your function actually returns: a tree of objects
Don't take the last section on faith. Type a value into the component's message prop below and watch the exact object your function would return. This is the thing handed back to the class — the “virtual DOM,” or vnode tree.
Left: the JSX you wrote. Right: the object it evaluates to — the value your function returns. The class receives the object on the right, never the syntax on the left. Note how {message} is simply a slot in the children array, filled with whatever the prop currently holds.
A few things worth noticing in that object:
- It's inert. It has no methods, no DOM references, no behaviour. You could
JSON-print most of it. It's the same kind of value as{x: 1}. - It's cheap. Building this tree is just allocating a few objects — far cheaper than touching the real DOM, which is the whole point (more in §6).
- It's recomputed every render. Each time your function runs, a brand-new object tree is produced. The class then compares the new tree to the previous one.
Terminology, pinned down
vnode = “virtual node”, one object like {type, props, children}. A vnode tree (or “the vDOM”) is the nested structure your whole function returns. “Virtual” only means “a description of DOM, not the DOM itself.”
§ 3 the one special vnode
The <host> trick: a vnode that means “me”
Every other tag in your tree maps to a child element to create. <host> is the exception, and understanding why is half the battle. There is no <host> element in HTML — the browser has never heard of it. It's a sentinel: a reserved type string the class recognises and treats specially.
When the class walks the returned tree and reaches the top node, it checks: is type === "host"? If so, it does not create an element. Instead it reads that vnode's props and applies them to the custom element instance itself — the this the browser already created when it saw your tag on the page. Then it renders the host's children into that element's content.
This is why Atomico insists the first node returned is always <host>: it's the seam between “the element the browser made for me” and “the tree I want inside it.” Everything you'd otherwise do in a class constructor — attach event listeners to the element, set host styles, opt into shadow DOM — you instead declare as props on <host>:
<host shadowDom // → this.attachShadow({mode:"open"}) onclick={handler} // → this.addEventListener("click", …) style={{ color: "rebeccapurple" }} // → applied to the element's :host > <h1>Hello {message}</h1> // → these become the element's content </host>
The diagram makes the split concrete. The same returned tree is divided by the class into two destinations: the host vnode's props go onto the element node; the host vnode's children go into its shadow tree.
The intuition in one line
In a vanilla web component you'd write this.shadowRoot.append(...) and this.addEventListener(...) in a constructor. <host> lets you describe all of that declaratively, as data, alongside your content — and the generated class does the imperative this.… calls for you.
Notice what just happened to your original question. “How does the class handle the JSX?” The class peels the <host> vnode off the top to configure itself, then renders the rest as children. <host> is the handshake.
§ 4 opening the box
What c() builds: the class, field by field
Now we can answer the literal question. c(yourFn, {props}) returns a class that extends HTMLElement — exactly the class you'd otherwise hand-write. Here is a faithful sketch of what it generates. Read the comments; each line replaces a chunk of vanilla ceremony.
function c(viewFn, { props } = {}) { return class extends HTMLElement { // 1 · derive observed attributes from your props schema static get observedAttributes() { return Object.keys(props ?? {}); // ["message"] } constructor() { super(); // 2 · a per-instance "row of lockers" for this element's hooks this._hooks = createHookCollection(() => this.update()); // 3 · remember the last vnode tree, so we can diff against it this._prev = null; // 4 · build the JS property/getter-setter for each prop defineProps(this, props); // el.message ⇄ attribute, with coercion } // 5 · browser lifecycle → trigger a render connectedCallback() { this.update(); } attributeChangedCallback() { this.update(); } // attr changed → re-render // 6 · THE HANDOFF — this is the heart of it update() { // a) run YOUR function, in this element's hook context, // passing current prop values. Get back a vnode tree. const vdom = this._hooks.run(() => viewFn(this._propValues)); // b) diff the new tree against the previous one and patch // the real DOM — peeling <host> onto `this`. render(vdom, this, this._prev); this._prev = vdom; // remember for next time } }; }
That's the entire mystery, in skeleton. The class is a small runtime wrapped around your function. It knows three things your function doesn't: when to call you (lifecycle & prop changes), where your hook state lives (the per-instance collection), and what to do with what you return (diff & patch). Map each numbered piece to the vanilla code it replaces:
- 1 ·
observedAttributes— vanilla makes you hand-list every attribute to watch. Atomico derives it fromprops, so declaring{message: String}is enough. - 2 · the hook collection — this is the “row of lockers” from the comparison report, but now you can see where it lives: one collection per element instance, stored on
this. That's why two<my-component>s on a page have independent state. - 4 ·
defineProps— generates the getter/setter forel.message, wires it to the attribute, and coerces types (theString/Numberin your schema). - 6 ·
update()— the handoff. Everything the next section dwells on.
A note on fidelity
The real Atomico is more optimised (async render scheduling, a shared renderer, symbol-keyed internals) and SSR-aware. But this skeleton is structurally honest: c() really does return an HTMLElement subclass whose lifecycle methods call your function and patch the result. You can even extends the returned class to add methods — proof it's a normal class.
§ 5 the answer, step by step
The render handoff, one frame at a time
This is the exact moment your question is about: your function has returned a vnode tree, and now the class takes over. Step through a complete render of <my-component message="world"> and watch the value travel from prop, into your function, out as a vnode, and into live DOM.
Colours track the four worlds from the key at the top: your function · vnode (data) · the class · real DOM. The prop value "world" is the thing to follow.
The cycle then repeats on demand. Anything that changes the inputs — setting el.message = "again", changing the message attribute, or a hook calling its setter — calls update() again. Your function reruns, returns a fresh vnode tree, and the class diffs it against the one it stored last time.
§ 6 the obvious objection
Why a vnode and not just innerHTML? The diff
A fair question at this point: if the function returns a description of the DOM, why doesn't the class just stringify it and slam it into shadowRoot.innerHTML on every render? Because that would be both slow and destructive — and seeing why explains the entire reason vnodes exist.
Replacing innerHTML throws away and rebuilds every node, every time. That blows away focus, scroll position, text selection, in-flight CSS animations, and the internal state of any nested web component. For a counter that only changed one digit, you'd have destroyed and recreated the whole subtree.
So instead the class keeps the previous vnode tree (the this._prev field from §4) and compares it to the new one. This comparison is called a diff (or reconciliation). It walks both trees in lock-step and emits the minimum set of real-DOM operations needed to make the old DOM match the new description — change this one text node, update that one attribute, leave everything else untouched.
Toggle below between “rebuild” and “diff” and change the message. Watch which real nodes get touched: rebuild flashes everything; diff touches only the text that actually changed.
The boxes are real DOM nodes in the shadow tree. A node flashes when the strategy recreates or mutates it. Rebuild discards the whole subtree; the vnode diff finds the single changed text node and updates only that — preserving the <input>'s focus and value, which rebuild wipes.
This is the real payoff of returning data instead of touching the DOM directly: because both the old and new UI are inspectable values, the runtime can compare them and act surgically. You couldn't diff two innerHTML strings meaningfully, and you certainly couldn't diff two imperative blocks of document.createElement calls. Vnodes exist precisely so the class can be lazy on your behalf.
§ 7 assembled
The whole machine, end to end
Put the pieces together and the handoff you asked about is no longer a black box — it's a short, ordinary pipeline. Here is the full circuit, from the tag on the page to the pixels and back.
Read it as a loop with one entry point and one exit:
- The browser sees
<my-component>and, becausecustomElements.defineregistered the classc()built, instantiates that class and firesconnectedCallback. connectedCallbackcallsupdate().update()runs your function with the current props, inside this instance's hook context. Your function returns a vnode tree — plain objects, the JSX having compiled toh(...)calls.- The class hands that tree to its renderer, which peels
<host>onto the element, diffs the rest against the previous tree, and patches the shadow DOM minimally. - Later, a prop change or a hook setter calls
update()again — back to step 3. The stored previous tree makes the next diff cheap.
The sentence to walk away with
Atomico's c() does not make your JSX into a class. It builds a class that runs your function and consumes the data your JSX compiled to. The function describes; the class decides when to ask and what to do with the answer. JSX is the message; the class is the messenger and the recipient.
And because the end product is a genuine HTMLElement subclass registered with the platform, none of this leaks out: to React, to Vue, to a plain HTML page, <my-component> is just a tag. The whole apparatus above runs invisibly behind one custom element — which is exactly the portability prize the comparison report opened with.