Topic 14.2
The Exceptions: Axes, Brushes, Zoom and Transitions
In one line
Some D3 helpers must write into the page: axes, brushes, zoom and transitions. Give each one an element that React renders empty, hand it over through a ref, run it in an effect, and always return a cleanup. Then StrictMode's double effect is harmless.
Think of it like this
A whiteboard in a meeting room that the cleaners wipe each evening. React is the room: it decides what is in it. The whiteboard is the one thing it lets a guest (D3) write on. The guest must wipe it before leaving, or the next guest finds the old writing under the new.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- effect
- Code that React runs after it has drawn the page, written with
useEffect. The place for anything that touches the real page. - cleanup
- The function an effect returns. React calls it before the effect runs again and when the component leaves the page.
- dependency list
- The array at the end of
useEffect([x]). The effect re-runs only when one of its values changed. - ref
- A box from
useRefwhose.currentholds the real DOM element after React has rendered it. - listener
- A function the browser calls when something happens, such as a wheel turn or a resize.
- transform (zoom)
- Three numbers from
d3.zoom:k(how much it is zoomed in),xandy(how far it is moved). - rescale
- Making a new scale from the base scale and the zoom transform, so the axis labels follow the zoom.
- idempotent
- Safe to do twice: doing it again leaves the same result as doing it once.
Step by step
01Give D3 an empty element and a ref
The Axis component renders one empty <g> and keeps a ref to it. React never puts children in it. The effect hands the element to D3 and redraws whenever the scale changes.
function Axis({ scale, ...props }) {
const ref = useRef(null);
useEffect(() => {
d3.select(ref.current).call(d3.axisBottom(scale)); // call: redraws in place
}, [scale]);
return <g ref={ref} {...props} />;
}02Meet StrictMode's second run
In development, React runs the effect, then its cleanup, then the effect again, on every mount. Your effect has to give the same picture after the second run as after the first.
03Call the axis, do not append it
The first example mounts three axes in StrictMode: one that appends a group per run, one that calls the axis on the same <g>, and one drawn as JSX with no D3. It counts the tick marks and axis lines.
The appending version leaves two axes on top of each other. The calling version has one. The JSX version has one and never ran an effect.
04Always return a cleanup
A listener added in an effect and not removed stays after the component leaves. The first example also mounts a tidy and a leaky component, unmounts both, and counts the listeners that are still alive.
05Zoom: D3 owns the gesture, React owns the picture
The second example attaches d3.zoom to the <svg> in an effect. The zoom handler only calls setT. Everything drawn (the line and the axis) comes from t.rescaleX(base), so React redraws it. The cleanup removes D3's listeners with .on(".zoom", null).
Try it yourself
- 1
More ticks
In the first example change every
ticks(4)toticks(8)(andtickFormat(4)totickFormat(8)). Thecallandjsxlines now print9 ticks: 0, 500, 1,000 up to 4,000, and theappendline prints18 ticks. D3 picks the "nice" step (Topic 6.1), and both ways ask the same scale. - 2
Remove the cleanup of the zoom
In the second example delete the line
return () => { svg.on(".zoom", null); };. The chart still works, becaused3.zoomlisteners of the same name replace each other, so StrictMode's second run overwrites the first. The listeners only leak when the<svg>itself is kept alive after the component is gone, which is why the cleanup is still correct to keep. - 3
Zoom by a different amount
Change
window.zoomTo(4, -600)towindow.zoomTo(8, -1800). The last line still starts with the wordszoomed 4x(that text is typed in the example), but the labels become weekly:May 18, May 25, June, Jun 08, Jun 15, Jun 22, Jun 29. The same code, a biggerk, a different transform.
Code & diagrams
Run it, then add a second effect run by hand in your head: StrictMode already did it for you. Only the version that appends is wrong, and only the leaky component outlives its unmount. The numbers are counts of real DOM nodes and real listeners.
Preview
Console
Nothing logged yet.Expected console output
append: effect ran 2 times -> 10 ticks, 2 axis line(s)
call: effect ran 2 times -> 5 ticks, 1 axis line(s)
jsx: no effect -> 5 ticks, 1 axis line(s)
Tidy resize listeners while mounted: 1 | after unmount: 0
Leaky resize listeners while mounted: 2 | after unmount: 2Scroll over the chart to zoom and drag to move: the axis labels change because React re-renders with a new rescaled scale. D3 added only listeners. The clip path hides the line outside the plot area.
Preview
Console
Nothing logged yet.Expected console output
whole year, x axis: 2025, April, July, October
zoomed 4x, x axis: April, May, JuneBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Append the axis instead of calling it
In the first example AppendAxis does append("g"). Make CallAxis do the same: write d3.select(g.current).append("g").call(d3.axisBottom(x).ticks(4)); in its effect and remove its cleanup.
Break #2
Add a listener without a cleanup
Keep Leaky as written: no function is returned from its effect.
Myth vs fact
Myth
StrictMode's double effect is a React bug, so I should turn StrictMode off.
Fact
It is a deliberate test that runs only in development. Turning it off hides the leak instead of fixing it, and the same leak happens in production whenever a user leaves a page and returns.
Myth
useEffect with an empty dependency array runs once, so cleanup is not needed.
Fact
It runs once per mount, and a component mounts again every time its page is shown again (and twice in development). Cleanup is for every mount.
Myth
D3 axes cannot be used in React.
Fact
They can, in an empty element behind a ref, called (not appended) in an effect. Many production React charts do exactly this.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Prefer
useLayoutEffectfor D3 calls that measure text or size, so the measurement happens before the browser paints and the axis does not flash at the wrong size. For ordinary drawing,useEffectis enough and does not block painting. - ▸
React 19 lets a ref callback return a cleanup function (
ref={(el) => { ...; return () => ...; }}), which keeps the axis code next to the element it belongs to. The effect-based form shown here works in every React version from 16.8. - ▸
Zoom through state re-renders the whole chart on every wheel tick. For a line of a few hundred points this is fast. For thousands of marks, keep the gesture in a ref, apply
transformto a<g>directly with D3, and commit to React state only when the gesture ends (theendevent). - ▸
A transition on elements React renders is overwritten by the next render, because React sets attributes back to its own values. Safe options: animate with CSS (
transition: width .3son a React-setwidth), animate inside an empty<g>D3 owns, or step the value with state andrequestAnimationFrame.
Remember this
- 1
The problem this solves:
d3.axisBottom,d3.brush,d3.zoom,d3.dragandtransition()do not return numbers. They create or change elements and attach listeners. You cannot write them as JSX, so you need a safe place where D3 may touch the page without React noticing. - 2
A ref and an empty element. React renders
<g ref={ref} />with no children. After React has put it in the page,ref.currentis the real element, and D3 may fill it:d3.select(ref.current).call(d3.axisBottom(x)). React never looks inside that<g>because it rendered no children there, so the two owners do not collide. - 3
Effects run after React has drawn.
useEffectruns after the first paint, soref.currentis set. Put every D3 call that touches elements inside it, with the scale in the dependency list:[x]. Whenxchanges (new data, new width), React runs the effect again and D3 redraws the axis in place. - 4
Call, do not append.
selection.call(axis)redraws an axis inside the same<g>: it uses a data join internally, so calling it twice gives one axis.selection.append("g").call(axis)adds a new group every time. This is the single most common D3-in-React bug, and StrictMode makes it visible at once (see the first example). - 5
Why StrictMode runs effects twice. In development React mounts your component, runs its effects, runs their cleanups, and runs the effects again, to prove that a component can be unmounted and remounted safely. An effect that is not safe to repeat shows up on your first screen instead of after a week in production. The production build runs effects once, but real unmounts (leaving a page, a tab) do happen, so the cleanup matters there too.
- 6
Cleanup is part of the effect. Return a function that undoes what the effect did: remove listeners (
svg.on(".zoom", null)), stop timers and transitions (selection.interrupt()), disconnect observers. Without it, each mount adds one more listener or timer, and a page you leave keeps running them. Edge case: if the effect only writes into the owned<g>, React removes the<g>when the component unmounts, so no element cleanup is needed, only listeners and timers. - 7
Zoom: the transform is React state.
d3.zoom()reports a transform{ k, x, y }on every wheel, drag or pinch. Save it withsetT(event.transform), derive a new scale witht.rescaleX(base)during render, and let React redraw the line and the axis. D3 owns only the gesture, React owns the picture. The base scale is never changed, soResetissetT(d3.zoomIdentity)plus onezoom.transformcall. - 8
Brushes and transitions follow the same shape. A brush lives in its own empty
<g>and reports[start, end]through a callback that callssetState(Topic 8.5). A transition belongs to elements React owns only if you let D3 animate their attributes: React would overwrite the attribute at the next render, so animate through a ref, or animate the data withrequestAnimationFrameand state (Topic 9.5), or use CSS transitions on attributes React sets (transition: width 300ms). - 9
You can also skip D3 for axes.
x.ticks(5)returns tick values andx.tickFormat()a formatter. Drawing them as JSX (<line>and<text>per tick) needs no ref, no effect and no cleanup, and renders on the server. You lose D3's tick-size options and its automatic text alignment, which is why many teams keep D3's axis for anything complicated.
Explain it without notes
Why does React's StrictMode run effects twice, and what kind of bug does that expose in a D3 chart?
What is the difference between selection.call(axis) and selection.append("g").call(axis) inside an effect?
Which part of a zoomable chart should live in React state, and why derive the scale instead of changing it?
When is it better to draw the axis as JSX than with d3.axisBottom?
Practice
Write an Axis component for a bottom axis that takes a scale prop, calls d3.axisBottom in an effect, and leaves the <g> empty again on cleanup. Why does it list [scale] as its dependency?
A teammate's Brush component adds d3.brushX() to a <g> in an effect and never removes it. Describe two problems this causes, and fix the effect.
Trade-offs
- ↔
Refs and effects give you every D3 behaviour (brush, zoom, drag), at the price of a second way to manage the page. Keep each ref'd element small and its effect short.
- ↔
Drawing ticks as JSX keeps one owner and works on the server, but loses D3's alignment details and you maintain more code.
Done when you can
I can hand D3 an empty element through a ref and draw an axis into it.
I can explain why StrictMode runs an effect twice and what a safe effect looks like.
I can tell
call(axis)fromappend("g").call(axis)and say which is safe to repeat.I can return a cleanup for listeners, timers and observers.
I can keep the zoom transform in state and rescale the axis from it.