Hoistable SVG Defs, Take Two: Impersonating the DOM

Replacing the useEffect registry with a fake portal container, the trick React Aria's collections use to let react-dom's commit phase do the bookkeeping.

Published
In my previous post, we looked at creating hoistable defs in React.
Take One, recapped

SVG has elements that don't render directly — <marker>, <linearGradient>, <clipPath> — but define something for graphics elements to reference with url(#id). These belong in a <defs> element, and references resolve document-wide. That can lead to id-clashes, and it leaves React components with a conceptual problem. A component can't own the definitions it depends on.

I wanted what React 19 does for <head> tags, but for <defs>:

  1. Definitions colocated with the component that uses them
  2. A single <defs> element
  3. No duplicated definitions
  4. Definitions removed when the component that owns them unmounts

The solution was:

  • A <DefsProvider> to store a reference to a React-rendered <defs> DOM node using a ref callback into state, shared through context.
  • Each component declares its definitions inside a <DefsPortal>, which portals them into that single <defs>.
  • Since portals append to the DOM rather than replace, deduplication needed a registry, a Map in state keyed by the def's id, where every portal instance registers itself in a useEffect and unregisters in its cleanup. The first instance to claim an id wins and renders the definition; the rest render null.

I can imagine this summary is not very clear so I recommend just reading the first post.

I ended it with a confession. The registry pattern works, but it tracks component mount and unmount by abusing useEffect setup and cleanup. React already knows which components are mounted in the Fiber tree, but we can't access that information .

So our registry shadowed a slice of that knowledge from the outside. It worked but it felt icky, against the grain.

The deduplication problem — "am I the first <defs> with this id in the whole component tree?" — is an instance of a parent component needing to know about its children. Jay Freestone catalogued a few solutions to this problem in his blogpost Updating React parents in response to changes in children :

  • Walking Children.toArray. This only sees direct children. Also, I find Children ugly and unidiomatic React. It's dated and the React docs recommend against using it .
  • Context registries with effects, more or less the defs registry pattern from last post.
  • One intriguing approach from Adobe's magnificent React Aria component library.

That last one me. React Aria's collection components — listboxes, menus, tables — face the problem in a harder form. For example, a <ListBox> needs the full ordered list of its options to implement keyboard navigation and selection, but the options are JSX, composed by the user, possibly nested in wrapper components the listbox can't see through.

So I went digging through React Aria's code, and found their solution very smart and out-of-the-box. It doesn't go against the grain of React, instead it uses React's internal mechanisms.

They render the children into a fake document , portaling into it with createPortal(children, fakeDocument), where fakeDocument is not a DOM node but a plain JavaScript class that implements just enough of the DOM interface (createElement, appendChild, insertBefore, removeChild) to satisfy react-dom.

Why does that even work? When react-dom commits, it has to tell the container it's rendering into about every mount and unmount. Usually this is the root element passed to createRoot but it can also be a portal's target element . react-dom will call appendChild and removeChild on that container. That's what committing means. So hand a portal a fake container and you get to eavesdrop on the commit phase. react-dom calls our appendChild on every mount, our removeChild on every unmount. The bookkeeping we rebuilt with useEffect in Take One, the reconciler gives for free.

That's react-dom writing into the fake document, and those writes are exactly the information we're after. The reconciler itself now reports which components are mounted. But that leaves the collection of children sitting outside React, in a plain JavaScript object — and whatever renders from the collection must re-render when it changes. The data needs to flow back out. Reading 'outside' state in a render is exactly what useSyncExternalStore is for. You give it a subscribe and a getSnapshot function, and React re-renders the subscribed component whenever the store reports a change. Here the fake document where the defs children are written into is the store.

Which makes this a strange case of "external store". Usually the store is, well, actually external — some state management library like Redux, or Jotai, or a browser API like matchMedia. This one is fed by react-dom's commit phase, through the only channel it offers the outside world, the DOM interface. What we're really subscribing to is the Fiber tree, the mount state that our registry from Take One could only shadow from the outside. React, plugged back into its private internals.

You might worry about the performance of all this extra rendering. Every change to the collection now takes two render passes. The first renders the portal into the fake document, and a second pass renders the items into the real DOM. The React Aria team raised this downside themselves in the Collections RFC . But that first pass only writes to plain JavaScript objects, which are far cheaper than real DOM nodes, and in their testing it never became a problem. If it's good enough for them, it's good enough for me.

So could the same trick fix the duplicate <defs> problem? Can we just import theirs?

We can't just import React Aria's solution. Parts of the machinery are public but the part we need, the intercepting fake Document class itself, is private . Also their needs are a lot heavier. Keyboard navigation and selection need the complete ordered list before item one can render. For Defs we need none of that, we just need a flat Map.

We'll impersonate the DOM ourselves. The component-facing API stays the same, declare defs inside a <DefsPortal>, reference them by id. What changes is where the portal goes. It no longer portals into the real <defs> element, but into a fake container. This is a place off to the side of the real DOM where the definitions gather before they're added for real. The only JSX that enters the fake container is the def declarations themselves:


function Arrow({ path }) {
return (
<>
{/* portaled into the fake container */}
<DefsPortal id="arrow">
<marker id="arrow" /* … */ />
</DefsPortal>
{/* becomes real DOM, rendered once */}
<path d={path} markerEnd="url(#arrow)" />
</>
);
}

Before we can build the fake container, we need to know what react-dom demands of one.

This is not really documented. I checked it by giving createPortal a Proxy object that logs every property access and forwards to the real implementation, mounting and unmounting a portal, and then reading the log. For react-dom 19, this returned the following properties being touched.

PropertyWhy
nodeTypeisValidContainer and namespace resolution — report 11, a DocumentFragment
addEventListenerreact-dom attaches its delegated event listeners to every portal container, capture and bubble
ownerDocumenthow react-dom finds createElement
nodeNamedev-mode container validation — reads undefined and carries on
createElementcreating our fake <def> elements
appendChild, removeChildthe commit calls: every mount and unmount lands here
getRootNodefeature-detecting shadow-DOM containment; undefined falls back to ownerDocument
documentElementnamespace lookup for the root host context
onclicka mobile-Safari click-delegation quirk
_reactListening*, _reactRootContainer, __reactProps$*react-dom's own bookkeeping, assigned as ad-hoc properties directly on the object — plain objects accept these for free

Each <DefsPortal> portals a single placeholder into the fake container, <Def />, really just the string "def" in a type-level disguise. On commit, react-dom calls createElement("def") on our container and appends whatever comes back. That plain DefElement object is our registry entry. That entry has to carry the definition's id and its JSX untouched, because <Defs> will later render that exact JSX for real. But props can't carry them. react-dom pushes every prop through its attribute pipeline, which stringifies values, so our JSX would arrive as "[object Object]". Children are worse. react-dom would render them into the fake container, and now we're implementing half the DOM.

The one thing that skips this pipeline is the ref. React calls the ref callback itself at commit, handing over the object createElement returned. A plain function call, everything by reference. So the placeholder gets no props except a ref, and the ref callback hands the real props to the fake element directly.


export function DefsPortal({ id, children }) {
const container = useContext(DefsContext);
const ref = useCallback((node) => {
node?.setProps({ id, children });
}, [id, children]);
return createPortal(<Def ref={ref} />, container as unknown as Element);
}

Refs attach during the commit phase, right after insertion, so the fake element has its props before any subscriber reads the registry. And because the callback's identity changes when id or children change, updates flow through the same channel.

See the full live code on StackBlitz.

The container is the portal target and the store:


// A stable reference: a fresh [] on every call would loop useSyncExternalStore
const EMPTY: Array<DefRecord> = [];
export class DefsContainer {
nodeType = 11; // DocumentFragment, per isValidContainer
ownerDocument = this; // react-dom finds createElement through here
childNodes = [];
createElement(type) {
return new DefElement(this);
}
appendChild(child) {
this.childNodes.push(child);
this.markDirty();
return child;
}
insertBefore(child, before) {
const index = this.childNodes.indexOf(before);
this.childNodes.splice(index, 0, child);
this.markDirty();
return child;
}
removeChild(child) {
const index = this.childNodes.indexOf(child);
if (index >= 0) {
this.childNodes.splice(index, 1);
this.markDirty();
}
return child;
}
addEventListener() {} // event delegation lands here; no-op
removeEventListener() {}
// --- The part React components talk to ---
subscribe = (fn) => {
/* Set of subscribers */
};
getSnapshot = () => {
/* dedupe childNodes by id, first in document order wins, cached */
};
getServerSnapshot = () => EMPTY; // portals don't run during SSR
markDirty() {
this.snapshot = null;
this.subscribers.forEach((fn) => fn());
}
}

<Defs> stops being an empty <defs ref={...}> waiting to be portaled into. It subscribes to the container with useSyncExternalStore and renders the deduplicated definitions itself:


export function Defs() {
const container = useContext(DefsContext);
const defs = useSyncExternalStore(
container.subscribe,
container.getSnapshot,
container.getServerSnapshot,
);
return (
<defs>
{defs.map(({ id, children }) => (
<Fragment key={id}>{children}</Fragment>
))}
</defs>
);
}

The whole trick in one diagram:

React treeDOM treeChartcontainerDefsProvideruseSyncExternalStoreDefs<defs><marker>Arrow<path>DefsPortal<def>A plain JS object impersonating a DOM node: the portal target and the external store.portal targetDefsContainerchildNodes<def> #arrowsetProps({ id, children }) via refmarkDirty() → notify subscribersFake document off to the side, where definitions gather before they're added to the DOM for real<svg><defs><marker> #arrow<g><path>commit1appendChild on commit2onStoreChange3added for realurl(#arrow)

Compare this to Take One's registry and notice everything that's gone:

  • No useEffect. Registration is mounting. react-dom calls appendChild when the portal mounts and removeChild when it unmounts; there's nothing left to synchronize.
  • No instanceId with useId. Ownership is document order. The snapshot dedupes by id, the first mounted instance wins, and when the winner unmounts the next definition surfaces in the recomputed snapshot automatically.
  • No initial renders of duplicated defs. The defs are written to the DOM just once, not added and then deduplicated like before.

The wishlist from last time still gets every check and this time without any icky feelings:

  • ✅ Definitions colocated with the component that uses them
  • ✅ A single <defs> element
  • ✅ No duplicated definitions
  • ✅ Definitions removed when the owning component unmounts

The useEffect registry of Take One bent a documented API out of shape. But the effect registry reconstructed mount state from the outside, shadowing what React already knew.

Here we bend an undocumented API into shape. The set of methods react-dom calls on a portal container is a private contract that can shift between React versions. The fake container gets told directly, by the reconciler itself, every time it commits.

It's still weird and a lot of code for what is, honestly, a minor problem. But this time it's less weird and done tastefully.

  1. React stores an internal data structure that tracks all the current component instances that exist in the application. The core piece of this data structure is an object called a "fiber".
    Mark Erikson: Blogged Answers: A (Mostly) Complete Guide to React Rendering Behavior
  2. React ARIA/Spectrum has a very neat, if a bit wild, solution. They provide a ‘collection’ component API that looks like this: [...] Their focus/state management (and more) requires knowledge of how many Item components there are, but each item might not be a direct child of a collection. Users may wish to wrap them for layout and/or styling. They perform a first-pass render to a fake ‘document’ inside a portal:
    Jay Freestone: Updating React parents in response to changes in children
  3. Pitfall: Using Children is uncommon and can lead to fragile code. See common alternatives.
    React docs API Reference: Legacy React APIs - Children
  4. A mutable element node in the fake DOM tree. It owns an immutable Collection Node which is copied on write.
    react-spectrum: Document.ts
  5. domNode: Some DOM node, such as those returned by document.getElementById(). The node must already exist. Passing a different DOM node during an update will cause the portal content to be recreated.
    React DOM docs API Reference: createPortal — Parameters
  6. The main limitation has been about composition: in our current implementation, <Item> must be a direct child of the collection component and cannot be within a wrapper component like <MySpecialItem>. This is a minor frustration for developers who expect these components to work like any other React component. This will become more important in React Aria Components where creating a wrapper with reusable styles will be common. Rather than walking the JSX tree to collect items, the new implementation will rely on React itself to build and efficiently update collections. It works by implementing a tiny version of the DOM with just the methods React needs (e.g. createElement, appendChild, etc.). Then, it uses a React portal to render the collection into this fake DOM. React takes care of rendering all intermediary wrapper components, and leaf components like <Item> are rendered as "host" elements (similar to real DOM nodes). This gives us access to the underlying items as if they were rendered directly to the DOM, but without needing to pay this cost for large collections. We use this information to construct a Collection using the same interface as in the old implementation so all of our existing hooks work with it. Note that props passed to <MyItem> must be manually passed through to the underlying <Item> for it to work properly. In addition, this implementation has two downsides: 1. It requires two renders whenever something in the collection changes. The first causes the portal to be rendered, which updates the fake DOM. It then needs to kick off a second render pass to render the items into the real DOM. However, because the first pass is rendering into a fake DOM, it is quite fast and in testing so far hasn't been a problem. 2. Our current implementation heavily uses the React key prop to identify items in collections. However, this will not work with the new implementation because key is not passed through from the <Item> element to the implementation of the Item component. Therefore, we will need to use a different prop name such as id that is passed through. This would be a breaking change to switch to by default, but for React Aria Components this is ok since it is a new library. We will need to think through how to release this in our existing React Aria hooks and React Spectrum components separately.
    React Spectrum RFC: React Aria Components - Collections
  7. @react-aria/collections exposes CollectionBuilder, createLeafComponent, createBranchComponent, BaseCollection, useCachedChildren, and the Hidden utilities but not the fake Document class itself.
    @react-aria/collections on npm