Topic 14.1
D3 for Maths, React for the DOM
In one line
In a React app, let D3 do what it does without touching the page (scales, line and area paths, array helpers, formats) and let React draw every mark as JSX. One owner per DOM node means no fights and no stale charts.
Think of it like this
A restaurant kitchen. D3 is the recipe card: it works out amounts (200 grams here, 340 pixels there). React is the cook: the only person who puts food on the plate. If the recipe card also reached out and moved things on the plate, two people would be rearranging one dish and nobody would know what it should look like.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- component
- A JavaScript function that returns what a piece of the page should look like. React calls it again whenever its inputs change.
- JSX
- The HTML-looking syntax inside JavaScript files. The browser can't read it: the build tool (or, on this page, the editor) turns it into plain function calls first.
- render
- React calling your component to find out what the page should look like now. Rendering is not drawing: React compares the result with the page and changes only what differs.
- key
- A label on each item in a list (
key={d.name}) that tells React which item is which between renders. - useMemo
- A hook that remembers a computed value (like a scale) and recomputes it only when the values you list change.
- ref
- A box (
useRef) that holds a real DOM element after React has rendered it. The way to hand an element to D3. - owner
- Whoever creates, changes and removes a DOM node. Each node needs exactly one.
- StrictMode
- A development-only wrapper that renders twice and runs every effect twice, to expose code that is not safe to repeat.
Step by step
01Start with marks you wrote by hand
A React component can return SVG. The first stage shows three bars with numbers typed straight into the JSX. It works, but changing the data means editing the markup.
02Let data and a D3 scale decide the numbers
Now the bars come from an array. d3.scaleLinear turns rides into pixels, and data.map turns each row into a <rect>. D3 touches no element here: it only returns a number.
The key on each <g> is the day's name, so React can tell the bars apart when the data changes.
03Take the data and the size as props, and keep scales in useMemo
The component now receives data and width, builds both scales with useMemo, and a band scale spaces the rows. Change data and the whole chart follows.
04Draw a line the same way: D3 makes the path text, React sets it
d3.line() is a function from an array of points to one string such as M0,12L5,9L10,14. In JSX it is one attribute: <path d={line(points)} />. The same idea covers d3.area, d3.arc and d3.geoPath.
Axes are the one D3 helper that writes elements instead of returning text, so they get their own rule in Topic 14.2.
const line = d3.line().x((d) => x(d.date)).y((d) => y(d.rides));
// ...
<path d={line(rows)} fill="none" stroke="#264653" />05Decide who owns what, and write it down
React owns every element it renders with JSX. D3 owns nothing in the page, except an element React renders empty and hands over through a ref. This picture is the rule for the rest of the phase.
Try it yourself
- 1
Change the gap between bars
In the first example change
.padding(0.2)to.padding(0.5). The Console now saysheight: 11.6: the band scale gives each bar half of its band instead of 80%. Only one number changed, and React redrew all seven rects. - 2
Move a bar to a different row
In the first example sort the data before drawing:
data={[...week].sort((a, b) => b.rides - a.rides)}. The Console now saysfirst width: 340(Tuesday, the busiest day, is first) and the bars reorder. Becausekeyis the day's name, each<g>is still the same day's mark, only moved. - 3
Give the second example a case of your own
Add
"huge": [{ name: "a", value: 1e15 }, { name: "b", value: 5e14 }]tocases. It prints2 bars | bad widths: 0 | drawn: the scale maps any finite number into the range (the bars are 200 and 100 pixels), so a huge value gives a long label, not a broken bar.
Code & diagrams
React 19 renders asynchronously: without flushSync the rects would not exist yet when the last line runs. The width is 253.72 = 2,979 ÷ 3,992 × 340, the same rule as Topic 0.2.
Preview
Console
Nothing logged yet.Expected console output
rects: 7 first width: 253.72244488977955 height: 19.333333333333332Each case is its own React root. A row with null or NaN is left out rather than drawn as NaN: the filter is the one place that decides. A real dashboard would also tell the reader how many rows it skipped (Topic 14.7 does for stations).
Preview
Console
Nothing logged yet.Expected console output
normal 2 bars | bad widths: 0 | drawn
empty 0 bars | bad widths: 0 | No data to draw.
one row 1 bars | bad widths: 0 | drawn
all zero 2 bars | bad widths: 0 | drawn
a null day 1 bars | bad widths: 0 | drawn
NaN 1 bars | bad widths: 0 | drawn
negative 1 bars | bad widths: 0 | drawnBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Draw a null as if it were a number
In the first example change ["Mon", 2979] to ["Mon", null].
Break #2
Let D3 remove what React drew
Keep the root in a variable (const root = createRoot(...)), render once with flushSync, then add d3.selectAll("g").remove(); and render again with week.slice(0, 5).
Break #3
Forget the key
In the first example delete key={d.day} from the <g>.
Myth vs fact
Myth
To use D3 in React, call D3 in useEffect and let it draw everything.
Fact
That works only if D3 owns a whole subtree and React never touches it. It throws away React's strengths (state, props, re-rendering) and every update has to be redrawn by hand. Letting React draw the marks and D3 supply the numbers is shorter and has no ownership fight.
Myth
A React chart is slow because it renders every mark as a component.
Fact
A few hundred SVG marks in React are fast. The limit is the DOM, not React: thousands of nodes slow any SVG chart (Topic 12.1). Beyond that, draw to a canvas (Topic 14.3).
Myth
Using the array index as key is fine for charts.
Fact
It is fine only for lists that never reorder, filter or insert. Charts sort and filter all the time, and with index keys the element of one row ends up showing another row's hover colour or focus.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
d3.scaleBand().domain(data.map(...))and aMapkeyed by name let a chart compute positions once per render and look them up by key. If the domain has duplicate names, D3 keeps the first and React logs a duplicate-key warning: both are signals that the data has two rows with one id. - ▸
Because render functions must be pure, build scales and path strings during render, but never create them inside
useEffectand store them in state: that adds an extra render and a frame where the chart shows old numbers. - ▸
React's reconciler compares attributes one by one. A 365-point
<path>is one element with one longdstring, so changing the data replaces one attribute. Drawing the same line as 365<line>elements would make React diff 365 elements on every update.
Remember this
- 1
The problem this solves: React keeps its own picture of the page and updates the real page to match it. D3's classic style (
select,append,remove) changes the page directly. When both touch the same elements, React's picture and the real page disagree, and you get doubled marks, charts that stop updating, or crashes. - 2
Most of D3 never touches the page.
d3-scale,d3-shape,d3-array,d3-format,d3-time-format,d3-interpolate,d3-scale-chromatic,d3-hierarchy,d3-delaunayandd3-geo'sgeoPathonly take numbers and return numbers, functions or strings. Onlyd3-selection,d3-transition,d3-axis,d3-brush,d3-zoomandd3-dragread and write elements. In React you use the first group freely and treat the second group with care (Topic 14.2). - 3
Marks become JSX. Where Phase 5 used a data join, React uses
data.map(...). One<rect>per row, withwidth={x(d.rides)}. A line is one<path d={line(data)} />:d3.line()returns the path text (the string that Topic 1.3 taught you to read), and React sets it as thedattribute. When the data changes, React re-renders and updates only the attributes that changed. - 4
Keys do the job of the key function. Topic 5.4's key function told D3 which element belongs to which row. React's
keyprop does the same. Use a stable id from the data (d.name,d.id), not the position in the array: with position keys, sorting the data makes React reuse the element of row 0 for a different station, and anything attached to that element (a hover colour, a CSS transition, focus) follows the position instead of the station. Leavingkeyout prints a console warning. - 5
**Build scales in
useMemo.** A scale is cheap to make, but a new scale object on every render is a new identity every render. Anything that depends on it (a memoised child, an effect that draws an axis) would run every time.useMemo(() => d3.scaleLinear()..., [data, width])gives the same scale until its inputs change. React may drop a memo to free memory, so use it for identity and speed, never for correctness. - 6
One owner per DOM node. The rule that keeps everything working: React owns every element it rendered, and D3 may only write into an element React left empty (a
<g ref={...} />, Topic 14.2). If D3 removes an element React still tracks, React's next update fails withNotFoundError: Failed to execute 'removeChild', which the Break it below reproduces. - 7
Awkward data needs a decision, not a crash. An empty list gives
d3.max([])=undefinedand a scale with aNaNdomain. AnullgivesNaNwidths. A list of zeros divides by zero. In a component the fix is the same as in Phase 3: decide what the chart says (a sentence, a gap, a zero-length bar) and make that choice in one place before any scale is built. The second example runs seven such cases.
Explain it without notes
Which D3 modules are safe to call during a React render, and which are not? Why?
Why should keys be ids from the data rather than array positions? Give a chart example.
Why do you build scales with useMemo, and what would be wrong with relying on it for correctness?
What happens if D3 removes an element that React rendered, and how do you avoid it?
Practice
Write Dots({ data, width }) that draws one circle per { id, value } row along a horizontal line, using d3.scalePoint for x, with key={d.id}. For an empty list it must return a <p> that says No data to draw.
The data [{ name: "A", value: 3 }, { name: "A", value: 5 }] has a duplicate name. What does React print, and how would you fix the component so that both rows appear?
Trade-offs
- ↔
D3 for maths plus React for marks gives full control and the smallest bundle (you import only the D3 modules you use), but you write the axes, the tooltips and the accessibility yourself. A library like Recharts (Topic 14.4) gives them for you with less control.
- ↔
One owner per node removes a whole class of bugs, at the cost of D3 transitions and brushes needing special handling (Topic 14.2).
Done when you can
I can say which D3 modules are pure maths and which write to the page.
I can draw bars and a line as JSX with D3 scales, with a stable
keyon every mark.I can explain why D3 must not remove or append elements that React renders.
I can make a chart component handle empty, null, NaN and all-zero data without printing NaN.
I can say what
useMemois for here, and why correctness must not depend on it.