Hoistable SVG Defs in React

Colocating SVG definitions with the components that use them, using portals and context. An overengineered but instructive exercise.

Published

SVG has elements that don't render directly<linearGradient>, <marker>, <clipPath>, <filter>, <mask> — but define something for graphics elements — like <path>, <circle>, <text>, and <image> — to use. The spec calls these "referenced elements" . They go inside a <defs> element, each with a unique id . Graphics elements reference them with url(#id).

Here's a simple example: an arrowhead <marker> defined in <defs>, referenced by a <path>:


<svg viewBox="0 0 200 80" width="150">
<defs>
<marker
id="arrow"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<path d="M0,0 L0,7 L10,3.5 z" fill="tomato" />
</marker>
</defs>
<path
d="M 15,40 L 175,40"
stroke="tomato"
strokeWidth="2"
markerEnd="url(#arrow)"
/>
</svg>

Notice that definitions add another layer: references with url(#id) cut across the SVG markup.

marker-end<svg><defs><g><marker>#arrow<path><path>

Actually browsers are very forgiving and allow definitions elements outside of <defs>. But if you know better, just don’t, keep them in <defs>.

It is recommended that, wherever possible, referenced elements be defined inside of a ‘defs’ element.

It's also allowed to write multiple <defs>. But I don't like combing through scattered <defs>, so I'll take the spec recommendation a step further and say that referenced elements have to be defined in a single <defs> element.

url(#id) references are resolved document-wide, not per SVG element. Render two SVGs on the same page that both define id="arrow", and every url(#arrow) reference on the page will resolve to whichever definition the browser encounters first. This causes ID collisions. One often runs into it with icon SVGs or using SVGs generated by other programs, like Figma or Illustrator.

Jim Nielsen wrote about a practical case of running into this, as did Anton Ball.

Avoiding collisions by not using inline SVG

If you don't need inline SVG, you can sidestep ID collisions entirely by loading SVGs via <img>:


import arrowSvg from './arrow.svg';
<img src={arrowSvg} alt="" />

Bundlers like Vite resolve the import to a URL, and the browser loads each SVG as its own document so IDs never bleed across. No collision, no ceremony.

The catch: <img> SVGs are isolated. You lose currentColor, CSS targeting of internal elements, and any interactivity. If you need those things, inline SVG is the way to go, and the rest of this post applies.

ID clashes are one problem. But I write SVG in React, and there's a deeper friction: a component does not own the definitions it depends on.

Let's try to turn our example into a React component:


const arrowId = "arrow"
function Arrow({ path, color = "#f00" }) {
return (
<path
d={path}
stroke={color}
strokeWidth="2"
markerEnd={`url(#${arrowId})`}
/>
);
}

The <marker> can't live inside the component, put another way, <Arrow> does not own it. It has to be defined elsewhere, in <defs>. Let's draw a box for the <Arrow> component in the tree diagram.

marker-end<svg><defs><g><marker>#arrow<path><path>Arrow component

See how that url(#arrow) reference punctures a hole through the component boundary? That's implicit coupling that breaks true encapsulation.

React 19 solved an analogous problem for HTML's <head> with special rendering behavior. Tags that belong in <head> like <link> and <meta> can be written anywhere in the component tree, and react-dom hoists them to <head> and deduplicates them .

React treeDOM treeAppNavbar<nav>SEO<link><meta><main><h1><html><head><link><meta><body><nav><main><h1>Hoisted by ReactHoisted by React

I want that, but for SVG <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

Let's try.

Remember, the SVG spec allows multiple <defs> elements, anywhere in an SVG. Browsers handle it fine. A component can just declare its own <defs> inline, right next to the shape that uses them.

With useId we can make the component instance-proof, a unique id per instance , to avoid clashes.


function Arrow({ path, color = "#f00" }) {
const id = useId();
return (
<>
<defs>
<marker
id={id}
markerWidth="10"
markerHeight="10"
refX="0"
refY="3"
orient="auto"
>
<path d="M0,0 L0,6 L9,3 z" fill={color} />
</marker>
</defs>
<path
d={path}
stroke={color}
strokeWidth="2"
markerEnd={`url(#${id})`}
/>
</>
);
}

While this works, each instance of <Arrow> gets its own <defs> with its own marker. Render 50 arrows, get 50 marker definitions in the DOM.

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

Halfway there.

We want definitions authored inside components but rendered inside a single <defs> at the top of the SVG.

Portals to the rescue!. Instead of moving components around, we'll use a Portal to reuse a location in the DOM. A Portal decouples who renders something (owner) from where it appears .

The typical portal targets a DOM element that already exists, a non-React DOM element, like a <div id="modal"> in your HTML, or document.body.

That's not the case here. The <defs> element is created by React, so we need to get a reference to it after React has rendered it.

That means the ref goes into state, not a plain ref object. A plain useRef doesn't trigger re-renders when it's set, <DefsPortal> would never know when <Defs> mounted and would keep rendering null. Putting it in state means React re-renders the consumers the moment the <defs> DOM node becomes available.

The state-setter itself is stable , so passing it as a ref callback, ref={setDefsEl}, is safe. It only fires on mount with the DOM node and unmount with null.

The DOM element reference is then shared via context so any <DefsPortal> in the subtree can reach the same <defs> DOM node.


function DefsProvider({ children }) {
const defsElState = useState<SVGDefsElement | null>(null);
// const [defsEl, setDefsEl] = defsElState;
return (
<DefsContext.Provider value={defsElState}>
{children}
</DefsContext.Provider>
)
}
export function Defs() {
const [, setDefsEl] = useContext(DefsContext)!;
return <defs ref={setDefsEl} />;
}
export function DefsPortal({
id,
children,
}) {
const [defsEl] = useContext(DefsContext)!;
if (!defsEl) {
return null;
}
return createPortal(children, defsEl);
}

Now <Arrow> can own its <marker> in the React tree, while the marker lands in <defs> in the DOM.


const arrowMarkerId = "arrow";
function Arrow({ path, color = "#f00" }) {
return (
<>
<DefsPortal id={arrowMarkerId}>
<marker id={arrowMarkerId}
markerWidth="10"
markerHeight="10"
refX="0"
refY="3"
orient="auto"
>
<path d="M0,0 L0,6 L9,3 z" fill={color} />
</marker>
</DefsPortal>
<path d={path} stroke={color} strokeWidth="2" markerEnd={`url(#${arrowMarkerId})`} />
</>
);
}

Putting it all together:


function Chart() {
return (
<DefsProvider>
<svg viewBox="0 0 800 400">
<Defs />
<Arrow path="M 10,50 L 200,50" />
<Arrow path="M 10,100 L 300,100" />
</svg>
</DefsProvider>
);
}

And as a diagram:

Parent treeOwner treeDOM treeAll components within this box can consume DefsContextdefsElContext value: defsEl (DOM ref to the defs element)ChartdefsElDefsProviderDefsArrowdefsDefsPortalpathmarkerdefsElContext value: defsEl (DOM ref to the defs element)ChartdefsElDefsProviderDefsArrowdefsDefsPortalpathmarker<svg><defs><marker> #arrow<g><path>ref callbackportal targeturl(#arrow)

This is a step in the right direction.

However, passing a component through a Portal it will not replace the children in that DOM node, it will append to it . This means every instance of <Arrow> portals its own <marker> into <defs>. Render three arrows, get three identical marker definitions.

The browser doesn't mind, it just takes the first id match, so you could stop here.

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

Almost. I do mind duplicates, so I won't stop here.

Fixing duplicate definitions reveals a deeper problem: coordination.

React's data flow is top-down within a subtree, a parent can coordinate its children, but siblings don't know about each other.

Here we need something that answers "Am I the first one, or did someone else already claim this ID?"

However many <Arrow> instances mount, <defs> should contain exactly one <marker id="arrow">. When the last one unmounts, the marker goes with it.

We’ll need to track two distinct identities:

  • id: the definition's meaningful, public name, the one used in url(#arrow). All instances of <Arrow> use the same one.
  • instanceId: a private, per-instance identity. Tells the registry which specific component instance owns the def and is responsible for rendering it.

Briefly, id answers "what is this def?". instanceId answers "who registered it?".

We’ll store both in a Map.


function DefsProvider({ children }) {
const [defsEl, setDefsEl] = useState<SVGDefsElement | null>(null);
const [registry, setRegistry] = useState<DefsRegistry>(() => new Map());
const value = useMemo(
() => ({ defsEl, setDefsEl, registry, setRegistry }),
[defsEl, setDefsEl, registry, setRegistry],
);
return <DefsContext.Provider value={value}>{children}</DefsContext.Provider>;
}
export function Defs() {
const { setDefsEl } = useContext(DefsContext)!;
return <defs ref={setDefsEl} />;
}
export function DefsPortal({ id, children }) {
const { defsEl, registry, setRegistry } = useContext(DefsContext)!;
const instanceId = useId();
const tag = isValidElement(children) ? children.type : null;
useEffect(function registerDef() {
setRegistry((prev) => {
if (prev.has(id)) {
if (process.env.NODE_ENV !== 'production') {
const existing = prev.get(id);
if (existing.tag !== tag) {
console.warn(
`DefsPortal: id "${id}" is already registered as <${existing.tag}> but this instance is <${tag}>. Two different definition types share the same id.`
);
}
}
return prev;
}
const next = new Map(prev);
next.set(id, { instanceId, tag });
return next;
});
return function unregisterDef() {
setRegistry((prev) => {
if (prev.get(id)?.instanceId !== instanceId) {
return prev;
}
const next = new Map(prev);
next.delete(id);
return next;
});
};
}, [id, instanceId, tag, setRegistry]);
if (!defsEl) {
return null;
}
const owner = registry.get(id);
if (owner && owner.instanceId !== instanceId) {
return null;
}
return createPortal(children, defsEl);
}

Oof, yes, useEffect, icky. React already tracks which components are mounted internally in the Fiber tree , a linked list of nodes storing state and connections. But Fiber is a private implementation detail , so there's no API to ask "who else is mounted right now?"

So we rebuild that knowledge in our own registry, using the only seam React gives us: Effects. We abuse the useEffect setup and cleanup to observe component mount and unmount. That's really not what it's meant for.

Now we can check off the last item on our list.

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

Blissful, yet at the same time, this pattern leaves a sour taste in my mouth. More on this in the Closing section.

One thing to be aware of: the registry deduplicates by id, and the first instance to register wins. This means the definition must be identical across all instances. If your <Arrow> takes a color prop that affects the marker, every arrow will render with the first instance's color. Definitions that need to vary per instance need different IDs.

This pattern and SSR

DefsPortal portals into a DOM node obtained via a ref callback. On the server there is no DOM, so defsEl is null and the portal returns null, meaning no definitions are rendered in the server HTML.

The SVG elements themselves render fine: paths, circles, text all appear on first paint. What's missing are the referenced decorations like markers, gradients, clip-paths, filters. The browser silently ignores a url(#id) reference when no matching definition exists.

Once hydration runs, the ref callback fires, defsEl is set, and the portals render the definitions into <defs>.

There’s one more architectural choice: where does the DefsPortal hoist to?

This is the approach above. Each SVG gets its own <DefsProvider> and its own <defs> element. Definitions stay scoped to the SVG they’re used in.


<DefsProvider>
<svg viewBox="0 0 800 400">
<Defs />
<Arrow path="M 10,50 L 200,50" />
<Arrow path="M 10,100 L 300,100" />
</svg>
</DefsProvider>

This is clean and self-contained. Each SVG is independent, you can mount and unmount them without affecting others. The downside: if two separate SVGs on the same page both render <Arrow>, each gets its own copy of the marker definition. You’re back to duplication, just scoped per-SVG instead of per-instance.

A single <defs> element shared across all SVGs on the page. This requires a root-level invisible SVG somewhere in your app:


// In your layout or app root
<DefsProvider>
<svg width="0" height="0" style={{ position: ‘absolute’ }}>
<Defs />
</svg>
{/* Rest of the app — any SVG anywhere can use DefsPortal */}
{children}
</DefsProvider>

Every <DefsPortal> in the entire app hoists to the same <defs> node. One marker definition, no matter how many SVGs use it. This completely eliminates ID collisions since each definition exists exactly once in the document.

This hidden SVG must be mounted before any component tries to portal into it, so it lives for the lifetime of the app. A small trade off is that your SVGs aren’t fully self-contained, they depend on a definition that lives outside their own <svg> element.

If your SVGs are isolated, scope definitions to the nearest SVG. If you have many SVGs sharing the same definitions, or you want zero duplication across the whole page, go global.

Is this overengineered? Yes. Context, portals, a registry, useEffect for lifecycle tracking, a whole lot of machinery to put a DOM node in the right place. For most cases, scattering a few duplicate <defs> is fine.

But it was a nice exercise in React. It uses a colorful cast of the React API, useId, portals, ref callbacks, context, and useEffect in ways that are all slightly off from their usual applications and that’s where you learn how the pieces actually fit together.

  1. SVG allows graphical objects to be defined for later reuse. To do this, it makes extensive use of IRI references [RFC3987] to these other objects. For example, to fill a rectangle with a linear gradient, you first define a ‘linearGradient’ element and give it an ID, as in: <linearGradient id="MyGradient">...</linearGradient> You then reference the linear gradient as the value of the ‘fill’ property for the rectangle, as in: <rect style="fill:url(#MyGradient)"/> Some types of element, such as gradients, will not by themselves produce a graphical result. They can therefore be placed anywhere convenient. However, sometimes it is desired to define a graphical object and prevent it from being directly rendered. it is only there to be referenced elsewhere. To do this, and to allow convenient grouping defined content, SVG provides the ‘defs’ element.
    SVG1.1 Spec: 5.3 Defining content for reuse, and the ‘defs’ element
  2. The ‘defs’ element is a container element for referenced elements. For understandability and accessibility reasons, it is recommended that, whenever possible, referenced elements be defined inside of a ‘defs’. [...] Creators of SVG content are encouraged to place all elements which are targets of local IRI references within a ‘defs’ element which is a direct child of one of the ancestors of the referencing element
    SVG1.1 Spec: 5.3.2 The ‘defs’ element
  3. In HTML, document metadata tags like <title>, <link>, and <meta> are reserved for placement in the <head> section of the document. In React, the component that decides what metadata is appropriate for the app may be very far from the place where you render the <head> or React does not render the <head> at all. In the past, these elements would need to be inserted manually in an effect, or by libraries like react-helmet, and required careful handling when server rendering a React application.
    In React 19, we’re adding support for rendering document metadata tags in components natively: [...] When React renders this component, it will see the <title>, <link> and <meta> tags, and automatically hoist them to the <head> section of document. By supporting these metadata tags natively, we’re able to ensure they work with client-only apps, streaming SSR, and Server Components.
    React v19 — Support for Document Metadata
  4. useId returns a unique ID string associated with this particular useId call in this particular component.
    React Docs: useId
  5. A portal only changes the physical placement of the DOM node. In every other way, the JSX you render into a portal acts as a child node of the React component that renders it. For example, the child can access the context provided by the parent tree, and events bubble up from children to parents according to the React tree.
    React Docs: createPortal
  6. The set function has a stable identity, so you will often see it omitted from Effect dependencies, but including it will not cause the Effect to fire. If the linter lets you omit a dependency without errors, it is safe to do.
    React Docs: useState — Caveats
  7. When passing a component through a Portal it will not replace the children in the node you provide, it will append to it.
    jjenz: Smarter Dumb Breadcrumb — Portals
  8. 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", which contains metadata fields that describe: what component type is supposed to be rendered at this point in the component tree, the current props and state associated with this component, pointers to parent, sibling, and child components, and other internal metadata that React uses to track the rendering process.
    Mark Erikson: Blogged Answers: A (Mostly) Complete Guide to React Rendering Behavior
  9. The old react, the stack one, was like this innocent recursive tree walker that just kept diving into components [...] except that ladder was welded directly to the javascript engine's callstack, so once you're halfway down you can't stop. So the react team basically said screw the whole model and built their own stack. Not metaphorically, literally. That's what fiber is. It's an entire fake callstack implemented as a linked structure of nodes. Each fiber is like a tiny memory cell react controls completely, storing the component type, props, state, the children pointer, sibling pointer, parent pointer, priority info, effects flags, and all this other internal stuff.
    @infinterenders on X