Command Palette

Search for a command to run...

Hectal
PHASE 14Advanced ~27 min· topic 2 of 7

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 useRef whose .current holds 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), x and y (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.

Axis.jsxwhole filejsx
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.

Meet StrictMode's second rundiagram
Rendering diagram…

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. 1

    More ticks

    In the first example change every ticks(4) to ticks(8) (and tickFormat(4) to tickFormat(8)). The call and jsx lines now print 9 ticks: 0, 500, 1,000 up to 4,000, and the append line prints 18 ticks. D3 picks the "nice" step (Topic 6.1), and both ways ask the same scale.

  2. 2

    Remove the cleanup of the zoom

    In the second example delete the line return () => { svg.on(".zoom", null); };. The chart still works, because d3.zoom listeners 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. 3

    Zoom by a different amount

    Change window.zoomTo(4, -600) to window.zoomTo(8, -1800). The last line still starts with the words zoomed 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 bigger k, a different transform.

Code & diagrams

Three axes in StrictMode, and listeners that leak New tab

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

Press Run to see the page.

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: 2
Zoom: D3 owns the gesture, React owns the picture New tab

Scroll 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

whole year, x axis: 2025, April, July, October
zoomed 4x, x axis:  April, May, June

Break 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.

terminal
$ Console
── what you'll see ──
call: effect ran 2 times -> 10 ticks, 2 axis line(s)
In the page: two axes drawn exactly on top of each other. Text looks bold, and tick labels are doubled.

Break #2

Add a listener without a cleanup

Keep Leaky as written: no function is returned from its effect.

terminal
$ Console
── what you'll see ──
Leaky resize listeners while mounted: 2 | after unmount: 2
With the cleanup (`Tidy`) it prints 1 while mounted and 0 after unmount.

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 useLayoutEffect for 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, useEffect is 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 transform to a <g> directly with D3, and commit to React state only when the gesture ends (the end event).

  • ▸

    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 .3s on a React-set width), animate inside an empty <g> D3 owns, or step the value with state and requestAnimationFrame.

Remember this

  1. 1

    The problem this solves: d3.axisBottom, d3.brush, d3.zoom, d3.drag and transition() 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. 2

    A ref and an empty element. React renders <g ref={ref} /> with no children. After React has put it in the page, ref.current is 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. 3

    Effects run after React has drawn. useEffect runs after the first paint, so ref.current is set. Put every D3 call that touches elements inside it, with the scale in the dependency list: [x]. When x changes (new data, new width), React runs the effect again and D3 redraws the axis in place.

  4. 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. 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. 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. 7

    Zoom: the transform is React state. d3.zoom() reports a transform { k, x, y } on every wheel, drag or pinch. Save it with setT(event.transform), derive a new scale with t.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, so Reset is setT(d3.zoomIdentity) plus one zoom.transform call.

  8. 8

    Brushes and transitions follow the same shape. A brush lives in its own empty <g> and reports [start, end] through a callback that calls setState (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 with requestAnimationFrame and state (Topic 9.5), or use CSS transitions on attributes React sets (transition: width 300ms).

  9. 9

    You can also skip D3 for axes. x.ticks(5) returns tick values and x.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

01

Why does React's StrictMode run effects twice, and what kind of bug does that expose in a D3 chart?

02

What is the difference between selection.call(axis) and selection.append("g").call(axis) inside an effect?

03

Which part of a zoomable chart should live in React state, and why derive the scale instead of changing it?

04

When is it better to draw the axis as JSX than with d3.axisBottom?

Practice

01

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?

02

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) from append("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.