React inside a custom element, once
Wrapping a React tree as a custom element is easy; not shipping React a second time into a host that already has it is the actual engineering, and it changes what the element is allowed to assume.
The gate is hand-written DOM and that was the right call for a component with one button and a badge. The trace view is not. It is a React tree built in “Making an agent’s work legible,” it renders a tool-calling loop that can fail halfway through, and rewriting it in vanilla DOM to get it into a host would be an act of self-harm.
So you wrap it. That part takes about fifteen lines. The engineering is everything after it: what the element is allowed to assume about the page it lands in, and how much React you are willing to ship into a host that already has some.
The wrapping is the easy half
React 19 closed the interoperability gap in both directions. The release post states it directly: “React 19 adds full support for custom elements and passes all tests on Custom Elements Everywhere.” Custom Elements Everywhere is a third-party interoperability suite rather than React’s own tooling, and its React results page is where the 100% figure across its sixteen tests comes from.
That fact is about the direction where a React host renders your tag, and it is worth knowing precisely because it decides how a host passes you data. On the client, React 19 assigns a prop as a property when a property of that name already exists on the element instance at construction time, and as an attribute otherwise. During server rendering, props whose values are objects, symbols, functions or false are omitted rather than serialised. If your element only defines its properties lazily, a React host will quietly send strings into an API you designed for objects.
The direction this lesson is about is the other one: a React tree living inside your element. Mounting a root into a shadow root is an unremarkable use of a documented API, and react.dev does not name it as a recipe, so treat the shape below as this course’s construction rather than an official pattern.
class AgentTraceElement extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' })
const mountPoint = document.createElement('div')
root.appendChild(mountPoint)
this._root = createRoot(mountPoint)
this._root.render(<AgentTrace />)
}
disconnectedCallback() {
this._root?.unmount() // React does not do this when the element is removed
}
}
customElements.define('agent-trace', AgentTraceElement)Two details in there are load-bearing. The unmount() is yours to call: removing the host element from the document does not tear down a React root, and in a host that mounts and unmounts a panel repeatedly, the leak compounds. And connectedCallback can fire more than once, because moving an element in the DOM disconnects and reconnects it. MDN’s custom elements guide documents the newer connectedMoveCallback, which fires instead of the disconnect-and-connect pair when the host uses Element.moveBefore() — so a move no longer has to cost you a full remount, if you implement it.
For styles inside that root, prefer a constructed stylesheet over a <style> per instance: MDN records adoptedStyleSheets as Baseline widely available since March 2023 (checked 2026-09-03), which parses the CSS once and shares it across every instance the host renders.
Get the singleton claim exactly right
The received wisdom is “React is not a singleton, so two copies break hooks.” React’s own documentation says the opposite of the first clause. The invalid hook call warning page states: “In general, React supports using multiple independent copies on one page (for example, if an app and a third-party widget both use it). It only breaks if require('react') resolves differently between the component and the react-dom copy it was rendered with.”
So the failure condition is narrow and specific: hooks break when a component’s react import and the react import of the react-dom that rendered it resolve to different module instances, within the same render. Two entirely separate React copies, each rendering its own tree with its own react-dom, are fine. This distinction is not pedantry: it is the difference between a debugging session that looks for a misconfigured shared dependency and one that looks for a second copy and deletes it, which will not fix anything on its own.
What the element is allowed to assume
This is the part that changes the component rather than the build. Whichever copy of React you end up on, the element is a separate tree in a separate root inside a shadow boundary, and that forecloses a set of assumptions a React developer makes without noticing.
- No host context. A separate root cannot read the host’s providers, and on a separate module instance the context objects are not even the same objects. No theme provider, no router, no query client, no auth context. Everything comes in as attributes and properties and goes out as events — the same channel the boundary lesson already forced you into.
- No assumption that you are the only root. A host may render several gates and traces on one page, so anything you attach to
documentorwindowis shared with your other instances. Scope listeners to your own root and clean them up indisconnectedCallback. - No assumption about React’s version if you externalise. Taking the host’s React means inheriting their version, their development or production build, and their upgrade schedule. Your component now has a compatibility range it must state and test, which is a line in the graft contract, not a detail.
The default that survives contact with all three hosts is: bundle React, externalise nothing, and offer an externalised build as a documented variant for hosts that ask. The Angular host and the plain HTML page have no React to borrow, and a variant that only exists for one host in three is a variant you build when that host asks for it, with the number that justifies it in hand.
Where people get burned
That number is the one your bundle report gives you, and ARTIFACT.md has two rows waiting for it: bundle size gzipped, and bundle size with the framework externalised. Do not estimate either. A platform team’s objection to a second React copy is answered by a measured difference in kilobytes and a measured time to first render, and by nothing else — least of all by a remembered figure for a version of React neither of you is running.
Retrieval check
A host engineer says “we already have React 19, why is your bundle shipping its own copy? That will break hooks.” Answer both halves.
Check your answer
The second half first, because it is wrong and correcting it changes the conversation. React’s own documentation says multiple independent copies on one page are supported; hooks break only when a component and the react-dom that rendered it resolve different react module instances within the same render. A bundled element renders its own tree with its own react-dom from the same build, so that condition cannot arise. Nothing about their tree is affected.
The first half is a real cost and you answer it with the measurement: here is the gzipped size bundled, here is the size externalised, here is the difference, and here is what externalising asks of them — a build-time arrangement to supply React, a version range we then have to test against, and a coupling between their upgrade schedule and our releases. If the difference matters to them, take the variant. If it does not, the bundled build is the one that also runs unchanged in the two hosts that have no React at all, which is the reason it is the default.
Check your recall
Answer from memory — no scrolling back.
Hands on
Ship the trace as an element, and measure both builds
Done when: ARTIFACT.md has the bundle-size row and the framework-externalised row filled in with measured gzipped numbers from your own build output, plus a one-sentence default decision naming which build ships and the host fact behind it. The trace renders inside the gate’s host page with no invalid hook call in the console.
- Wrap the trace component from “Making an agent’s work legible” as
<agent-trace>: shadow root, mount point,createRootinconnectedCallback,unmount()indisconnectedCallback. - Define every public property on the class before anything can set it, and take the run data in as a property rather than an attribute. Confirm from a React host that you receive the object and not a string.
- Build it twice: once with React bundled, once with React and ReactDOM externalised. Record both gzipped sizes from the build output, not from an estimate, and put both in the measurement table in
learning/grafting-ui/ARTIFACT.md. - Load the bundled build into a page that already runs its own React and confirm the console is clean. Then look at what that page’s React and yours have in common at runtime, which is nothing, and notice that this is fine.
- Prove the leak. Add a mount/unmount toggle to the host page, cycle it twenty times with
unmount()commented out, and watch detached nodes accumulate in a heap snapshot. Restore the call and repeat. Record the difference. - Write the default decision in one sentence: which build ships by default, which host fact makes it the default, and what would have to be true for a host to get the externalised variant. If the sentence contains a size you did not measure, go back to step three.
- Bring both numbers and the sentence into the chat. I will argue for the externalised build using the host with the most React in it, and the only thing that will move me is your measured difference.
What this does not cover
Sharing a dependency between separately built and separately deployed applications is a different problem from bundling one, and it has its own failure mode with its own configuration. The Module Federation lesson takes the singleton condition named here and shows how a build configuration produces it, including how to reproduce it deliberately so it is recognisable in someone else’s repository. The lesson on orchestration covers what single-spa does that Module Federation does not, and the iframe lesson covers the option that isolates hardest and charges most.
Making the element look like it belongs is the module after that. The custom properties named in the shadow-boundary lesson become a real theming channel in the lesson on extracting the host brand, one token source is compiled per host in the lesson on token compilation, and the three-host demo is where this bundle either runs unchanged in all three or does not.
Read this next — primary source
React v19react.dev, 5 December 2024 — free. Meta documenting its own library
The release post is where the custom-element story stops being folklore: React 19 adds full support for custom elements, and the post spells out the rule that decides whether a prop you pass becomes a property or an attribute, and what happens to non-primitive props during server rendering. Read the whole post rather than the custom-elements paragraph, because the ref, hydration-error and metadata changes in it all touch a component you are about to run inside somebody else’s page. It says nothing about mounting a root inside a shadow root, which is the part this lesson has to construct rather than cite.
Stuck, curious, or think this lesson is wrong? Ask your teaching agent. The lessons are the scaffold; the conversation is where the learning gets unstuck.