Fig. 01
Who draws the chart?
Charts in React with SVG and d3, and why every part of the DOM needs exactly one owner.
You add a chart to a React app. You reach for d3, because that’s what charts are made with, hand it a ref, and it draws. A week later the data changes and the chart doesn’t. You add the data to the effect’s dependencies, and now every update draws a second chart on top of the first.
Nothing here is a bug in React or in d3. Both are doing exactly what they were told. The problem is that two libraries each think they own the same piece of the page, and neither knows the other is there.
This entry is about who owns what in a chart. A chart is a pipeline: data goes in, numbers come out (positions, sizes, angles), and the numbers become shapes in the DOM. Every stage needs one owner, and when two owners share a stage you get charts that are stale, duplicated, or correct only until the next render. The same bug shows up wherever an imperative library meets a declarative framework: maps, rich-text editors, video players.
By the end you’ll have built a bar chart, a line, a donut and a set of axes with React and SVG, using d3 only for the math, and you’ll know where any piece of chart code belongs: in d3, in React, or in CSS. You need to know React; you don’t need to know SVG or d3.
The data throughout is real: the UK’s electricity generation by source, 1990 to 2025. It’s a good dataset for this, because it has a story in it. In 1990 coal made 65% of the country’s electricity. In 2025 it made 0.1%.
1. Two owners, one DOM
Here’s the usual first attempt, and the bug it ships with. The figure shows the same bar chart
twice. The top one is d3 inside a useEffect, the way most tutorials write it; the bottom one is
the same scales with React doing the rendering. Above them is what’s normally hidden: how many
bars are really in each <svg>, and which year they show.
Interactive figure — needs JavaScript.
Press Next year and only the bottom chart moves. The effect’s dependency array is empty,
so it ran once, on mount, and never again. React re-rendered the component, but the <svg> it
renders has no children as far as React knows, so there was nothing for it to update. d3’s bars
are still showing 1990.
Now tick fix the effect to see what it takes to make d3 keep up. The obvious half is filling
in the dependency array, so the effect runs when the data changes. That alone makes things worse:
every run would call .append('g') and draw a new group of eight bars over the old ones, and
wherever an old bar was taller it would stick out above the new one. So the other half replaces
the append with a join on a one-item array, which creates the group on the first run and reuses it
on every run after. Press Next year: the charts now agree, and the <rect> count stays at eight.
It works, but look at what the fix is: code that compares what’s on the page with what should be there, and patches the difference. That’s reconciliation, written by hand, and React already does it for the chart below.
The bottom chart needed none of this, and its code is shorter. It’s the same two scales, and the bars are plain JSX:
import { max } from 'd3-array';
import { scaleBand, scaleLinear } from 'd3-scale';
export function BarChart({ data, width = 400, height = 200, padding = 10 }) {
const innerWidth = width - padding * 2;
const innerHeight = height - padding * 2;
const x = scaleBand()
.domain(data.map((d) => d.source))
.range([0, innerWidth])
.padding(0.2);
const y = scaleLinear()
.domain([0, max(data, (d) => d.twh)])
.range([innerHeight, 0]);
return (
<svg viewBox={`0 0 ${width} ${height}`}>
<g transform={`translate(${padding},${padding})`}>
{data.map((d) => (
<rect
key={d.source}
x={x(d.source)}
y={y(d.twh)}
width={x.bandwidth()}
height={innerHeight - y(d.twh)}
fill="var(--series-1)"
/>
))}
</g>
</svg>
);
}That’s the idea the rest of this entry builds on: d3 computes, React renders. d3’s scales are
pure functions of your data, and they work anywhere. Its DOM-manipulating half (d3-selection)
is a second renderer, and each node can only have one. So if React draws the
chart, what exactly is it drawing?
2. A chart is markup
It’s drawing SVG, and SVG is ordinary markup: a small set of shapes (<line>, <rect>,
<circle>, <ellipse>, <polyline>, <polygon>, <path>) that behave like any other element.
You style them with CSS, attach events to them, and inspect them in DevTools. That’s why React can
render them with no help: to React, a <rect> is a <div> with different attributes.
The one thing SVG adds is a coordinate system. You draw in your own units, and the viewBox
decides which part of that plane is on screen and how big it is. Move the pointer over the shapes
and drag the sliders: the readout shows where the pointer is on screen, and where it is in the
drawing’s units.
Interactive figure — needs JavaScript.
Two things are worth seeing. First, the origin is the top-left corner and y grows downwards:
move the pointer down and y goes up. Every chart in this entry flips its y-axis for that reason,
so that a bigger value draws higher. Second, the shapes never change. Only the viewBox does,
and the browser maps your units to pixels for you. Shrink size to 250 and every unit becomes
almost a pixel; grow it to 1000 and a unit is a quarter of one.
That mapping, from your drawing’s units to the screen, is a function the browser runs. A chart needs one more of those functions, from the data’s units to your drawing’s: which point in the drawing stands for “2010”? Which height stands for 382 terawatt-hours?
3. A scale is a function
That second function is a scale, and it’s the part of d3 worth bringing along. A scale takes
a domain (the span of your data) and a range (the span of your drawing), and maps one onto
the other. scaleLinear().domain([0, 400]).range([240, 0]) is a function: give it 200 and it
returns 120. It doesn’t touch the DOM. It doesn’t know React exists. That’s what makes it safe to
call inside a render.
The figure draws the UK’s total generation for every year since 1990. The domain is the one choice
in it you have to make: start the bars at zero, or let extent take the smallest and largest
values in the data, which is what many examples do.
Interactive figure — needs JavaScript.
With the domain from zero, a bar’s height says what its value says: 2024 generated 71% of the
2005 peak, and its bar is 71% as tall. Switch to extent and the same data tells a different
story. The smallest value in the data now maps to the bottom of the chart, so 2024’s bar has a
height of zero. It vanishes. A 29% decline now looks like the country switched its power off.
Nothing is wrong with the code; the domain is a decision about meaning, and d3 can’t make it for you. For bars the rule is firm, because a bar encodes its value as length, and a length measured from anywhere but zero lies. For a line, which encodes value as position, a tighter domain can be fair, as long as the axis says where it starts. Scales give you positions and sizes. Next: what to draw at them.
4. A line, one layer at a time
A line chart is the plainest example of React rendering what d3 computed, and building it in four steps shows where each concern belongs. The data is coal’s share of UK electricity. A share already has a natural domain, 0 to 100%, so the y scale uses that rather than the data’s extent.
Step through the figure. The code panel lights the lines each step adds, and the readout above the chart shows the selected year going through both scales, and what’s in the DOM as a result.
Interactive figure — needs JavaScript.
The scales never change between steps, and neither does the [x, y] readout: every step after
the first draws more things at the same positions. What changes is who handles each new concern.
- A line. Two scales, one array of
[x, y]pairs, one<polyline>. Thepointsattribute is the scales’ output written out as text, and that’s the whole chart. - Markers. A
<circle>per point. React renders 36 of them the same way it would render 36 list items, and they’re positioned by the samepointsarray. - Hover, in CSS. The markers lose their
randfillattributes and get a class instead. That moves their look into CSS, where a hover state and a transition cost two rules, with no state or effect in the component. Since SVG 2, a circle’sris a CSS property like any other, so it can transition (the rules are below). - Events. Since the markers are React elements, events are React props. Each marker is
wrapped in a focusable
<g>with a bigger, invisible circle to hit: a dot 6 units across is a hard target for a finger. Tick “show the hit areas” to see them, and use Tab to move between points.
The hover in step 3 is these two rules, and nothing in the component knows about it:
.marker {
fill: var(--ink);
r: 3;
transition: fill 400ms, r 400ms;
}
.marker:hover,
.marker-hit:hover .marker,
.marker-hit:focus-visible .marker {
fill: var(--hot);
r: 6;
}Notice what never appeared: no select, no append, no effect. Behavior went where it’s
cheapest. Geometry came from d3, structure and events from React, looks and motion from CSS.
Lines are points joined up, though. What about shapes that aren’t made of points?
5. Angles are numbers too
A pie chart doesn’t have x and y positions; it has angles. But the split is the same: d3 turns
values into angles with pie(), turns angles into path strings with arc(), and React renders
the paths. Both are pure functions. pie() returns objects with a startAngle and an endAngle
for each value, and arc() turns one of those into the d attribute of a <path>.
Hover a slice to see its numbers go through both, and drag the year to watch the mix change. The
innerRadius slider shows that a pie and a donut are one shape: a pie is a donut with no hole.
The hole is also where a donut keeps its label: the total, or the slice you’re on.
Interactive figure — needs JavaScript.
The d string, whose start the readout shows, is the entire slice: a move, an outer arc, a line,
an inner arc back. It’s text, and React treats it like any other attribute: when the year changes, React
updates eight attributes and the browser redraws.
The hover effect follows the same rule as the markers. Each slice is clipped by a circle a little smaller than the chart; on hover, CSS scales the circle to full size and the slice seems to pop out. React’s only part is rendering the clip path:
.arc circle {
transform: scale(0.9);
transition: transform 200ms cubic-bezier(0.45, 0, 0.55, 1);
}
.arc:hover circle,
.arc:focus-visible circle {
transform: none;
}So far, every change has been instant: new data in, new shapes out. Real charts usually move from one state to the next. Who should own that?
6. Animate the numbers, not the shapes
The tempting answer is to animate what’s on screen: you have the old path strings and the new
ones, so move one towards the other. d3 even has the function for it, interpolateString, which
finds the numbers in two strings and moves each one. The figure does exactly that, and alongside
it the alternative: animate the data from the old year to the new, and run every in-between
frame through pie() and arc().
Switch between years with “tween the paths” selected (slow motion helps), then do the same with “tween the numbers”. The readout follows the coal slice, the first one, and checks two things the browser needs for a correct arc: the arc should end on the circle, 140 units from the center, and its large-arc flag must be 0 or 1.
Interactive figure — needs JavaScript.
Tweening the paths fails in three ways, and the readout shows each one. The arc’s endpoint moves
in a straight line from its old position to its new one, cutting inside the circle, so the
slice’s edge sags. The large-arc flag, a 0-or-1 switch hidden in the path, gets interpolated too:
between 1990 and 2025 it slides from 1 to 0 through values like 0.72, which the browser refuses,
so coal’s slice isn’t drawn at all until the move ends. (Open the browser’s console and you’ll see
it reject every frame.) And slices that were empty in 1990, like solar, start as a path with no
arc in it: interpolateString pairs up whatever numbers it finds, and solar’s slice flies across
the chart.
Tweening the numbers has none of these problems, because it never produces a shape that arc()
didn’t draw. Every frame is a real donut of in-between data. The hook behind both is the same, and
small:
import { useEffect, useRef, useState } from 'react';
const ease = (t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2); // cubic in-out
const reducedMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
// Moves a value towards `target` over `duration` ms, one frame at a time. What it moves (numbers,
// strings, arrays of either) is up to `interpolate(from, to)`, which returns a function of t ∈ [0, 1].
export function useTween(target, interpolate, duration = 750) {
const [value, setValue] = useState(target);
const shown = useRef(value);
shown.current = value;
const key = JSON.stringify(target);
useEffect(() => {
const between = interpolate(shown.current, target);
if (reducedMotion()) return setValue(target);
const start = performance.now();
let frame = requestAnimationFrame(function step(now) {
const t = Math.min((now - start) / duration, 1);
setValue(between(ease(t)));
if (t < 1) frame = requestAnimationFrame(step);
});
return () => cancelAnimationFrame(frame);
}, [key, duration]);
return value;
}The lesson generalizes beyond pies. A shape is the output of your chart, and outputs don’t interpolate well; inputs do. Animate the stage that has meaning (values, angles, positions), and let the pure functions draw each frame. That leaves one piece most people still hand to d3’s DOM side: the axes.
7. Fences for the exceptions
Axes are where the rule gets tested. An axis is lines and text, tick values and labels, all of
which d3-axis will draw for you with axisBottom(scale). It needs a DOM node to draw into, which
looks like section 1’s mistake all over again.
It doesn’t have to be. The figure draws the same chart’s axes two ways: with a React port of
d3-axis (the same tick math, React owns every node), and with d3-axis itself, drawing into a
<g> that React renders empty and never touches. Switch between them and drag the start year.
Interactive figure — needs JavaScript.
Both are correct, and the picture doesn’t change. The difference is in the readout’s last
column: who made the tick nodes. The d3-axis version works because of two properties, and you
need both. The node is fenced off: React renders the <g> with no children, so it never has
an opinion about what’s inside. And the drawing is idempotent: d3-axis joins its ticks to data,
so running it again updates the ticks it drew last time instead of appending more. Section 1’s
fixed chart had both, which is why it worked; the difference is that d3-axis writes the join for
you.
import { useEffect, useRef } from 'react';
import { select } from 'd3-selection';
// A fenced-off node: React renders an empty <g> and never touches its children; d3-axis draws
// them. Safe because the axis joins its ticks to data, so running it again updates, not appends.
export function D3Axis({ axis, transform }) {
const ref = useRef();
useEffect(() => {
select(ref.current).call(axis);
});
return <g ref={ref} transform={transform} />;
}The React port is d3-axis’s own source, translated: the same tick values, positions and labels, but every node is a React element.
// A React port of d3-axis: the same tick maths, but React owns the DOM.
export function Axis({
orientation,
scale,
tickValues,
tickFormat,
tickArguments = [],
tickSizeInner = 6,
tickSizeOuter = 6,
tickPadding = 3,
noDomain,
...props
}) {
const offset = globalThis.devicePixelRatio > 1 ? 0 : 0.5; // as d3-axis; on a server it's undefined
const values =
tickValues ?? scale.ticks?.(...tickArguments) ?? scale?.domain();
const format = tickFormat ?? scale.tickFormat?.(...tickArguments) ?? identity;
const modifier =
orientation === Axis.Top || orientation === Axis.Left ? -1 : 1;
const oppositeAxis =
orientation === Axis.Left || orientation === Axis.Right ? 'x' : 'y';
const spacing = Math.max(tickSizeInner, 0) + tickPadding;
const transform =
orientation === Axis.Top || orientation === Axis.Bottom
? translateX
: translateY;
const position = (scale.bandwidth ? center : number)(scale.copy(), offset);
return (
<g
fill='none'
fontSize={10}
textAnchor={
orientation === Axis.Right
? 'start'
: orientation === Axis.Left
? 'end'
: 'middle'
}
{...props}
>
{!noDomain && (
<AxisDomain
orientation={orientation}
scale={scale}
offset={offset}
tickSizeOuter={tickSizeOuter * modifier}
/>
)}
{values.map((tick) => (
<g
key={tick}
className='tick'
transform={transform(position(tick) + offset)}
>
<line
stroke='currentColor'
{...{ [`${oppositeAxis}2`]: modifier * tickSizeInner }}
/>
<text
fill='currentColor'
dy={
orientation === Axis.Top
? '0em'
: orientation === Axis.Bottom
? '0.71em'
: '0.32em'
}
{...{ [oppositeAxis]: modifier * spacing }}
>
{format(tick)}
</text>
</g>
))}
</g>
);
}
Axis.Top = 1;
Axis.Right = 2;
Axis.Bottom = 3;
Axis.Left = 4;
function AxisDomain({ orientation, scale, offset, tickSizeOuter }) {
const range = scale.range();
const range0 = Number(range[0]) + offset;
const range1 = Number(range[range.length - 1]) + offset;
return (
<path
className='domain'
stroke='currentColor'
d={
orientation === Axis.Left || orientation === Axis.Right
? tickSizeOuter
? `M${tickSizeOuter},${range0}H${offset}V${range1}H${tickSizeOuter}`
: `M${offset},${range0}V${range1}`
: tickSizeOuter
? `M${range0},${tickSizeOuter}V${offset}H${range1}V${tickSizeOuter}`
: `M${range0},${offset}H${range1}`
}
/>
);
}
function identity(d) {
return d;
}
function translateX(x) {
return `translate(${x},0)`;
}
function translateY(y) {
return `translate(0,${y})`;
}
function number(scale) {
return (d) => Number(scale(d));
}
function center(scale, offset) {
offset = Math.max(0, scale.bandwidth() - offset * 2) / 2;
if (scale.round()) {
offset = Math.round(offset);
}
return (d) => Number(scale(d)) + offset;
}Which one should you use? The fence is quick, and gives you everything d3-axis does, including its transitions. The port is more code, but its ticks are React elements: you can style them with props, render them on the server, and test them like any other component. Either way, the rule held. Every node had exactly one owner.
What to take with you
- Two owners, one DOM: a library that changes the DOM inside an effect is a second renderer. React can’t update what it didn’t render.
- A chart is markup: SVG shapes are elements, and the
viewBoxis a scale the browser runs for you. - A scale is a function: d3’s math is pure and safe to call in a render. The domain is a decision about meaning; bars start at zero.
- One layer at a time: geometry from d3, structure and events from React, looks and motion from CSS.
- Angles are numbers too:
pie()andarc()are scales for circles; React renders their paths. - Animate the numbers, not the shapes: interpolate the data and let the pure functions draw each frame.
- Fence the exceptions: when a library must draw, give it a node React never touches, and make its drawing idempotent.
Behind all of it is one rule: every node in the DOM has exactly one owner. For charts that means d3 does the math, React owns the markup, and CSS owns how it looks and moves. When something must break the rule, it does so inside a fence.
The rule isn’t about charts. Anywhere an imperative library lives inside React (a map, a code editor, a video player, a canvas), ask the same question: which nodes does each side own, and does the imperative side run safely more than once? Libraries for these things usually have a React wrapper that answers it for you; when they don’t, a fence and an idempotent update are the pattern.
None of this means you should build every chart yourself. When performance starts to matter, interactions get complicated, or a team needs consistency, a well-built charting library earns its weight. I work on one, AG Charts, and it’s open source. Knowing what a chart is made of is how you tell whether you need one.
This entry is adapted from my React Summit 2025 talk, Efficient Data Visualisation with React and SVG, and an older meetup demo. The original code is at iMoses/svg-react-slides and iMoses/d3-examples. The data is from Our World in Data, based on Ember’s Yearly Electricity Data and the Energy Institute’s Statistical Review of World Energy, licensed CC BY 4.0.