Command Palette

Search for a command to run...

Hectal
PHASE 14Advanced ~26 min· topic 1 of 7

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.

a line is one pathwhole filejsx
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.

Decide who owns what, and write it downdiagram
Rendering diagram…

Try it yourself

  1. 1

    Change the gap between bars

    In the first example change .padding(0.2) to .padding(0.5). The Console now says height: 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. 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 says first width: 340 (Tuesday, the busiest day, is first) and the bars reorder. Because key is the day's name, each <g> is still the same day's mark, only moved.

  3. 3

    Give the second example a case of your own

    Add "huge": [{ name: "a", value: 1e15 }, { name: "b", value: 5e14 }] to cases. It prints 2 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

Scales from D3, marks from React New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

rects: 7 first width: 253.72244488977955 height: 19.333333333333332
Awkward data: seven cases, one guarded component New tab

Each 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

Press Run to see the page.

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 | drawn

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

Draw a null as if it were a number

In the first example change ["Mon", 2979] to ["Mon", null].

terminal
$ Console
── what you'll see ──
Received NaN for the `x` attribute. If this is expected, cast the value to a string.
Error: <text> attribute x: Expected length, "NaN".
rects: 7 first width: null height: 19.333333333333332
React warns first, then Chromium prints the SVG error.

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

terminal
$ Console
── what you'll see ──
Uncaught NotFoundError: Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.
An error occurred in the <g> component.
 
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.
Copied from a real run: React logs it twice because it retries the failed update once. The example's last lines then fail too (`Cannot read properties of null`), because React unmounted the chart. Removing only the `<rect>` elements gives no error at this point, because React removes the parent `<g>`, which is still in the page; the crash comes when it later touches a removed child.

Break #3

Forget the key

In the first example delete key={d.day} from the <g>.

terminal
$ Console
── what you'll see ──
Each child in a list should have a unique "key" prop.
 
Check the top-level render call using <svg>. See https://react.dev/link/warning-keys for more information.

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 a Map keyed 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 useEffect and 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 long d string, 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. 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. 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-delaunay and d3-geo's geoPath only take numbers and return numbers, functions or strings. Only d3-selection, d3-transition, d3-axis, d3-brush, d3-zoom and d3-drag read and write elements. In React you use the first group freely and treat the second group with care (Topic 14.2).

  3. 3

    Marks become JSX. Where Phase 5 used a data join, React uses data.map(...). One <rect> per row, with width={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 the d attribute. When the data changes, React re-renders and updates only the attributes that changed.

  4. 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 key prop 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. Leaving key out prints a console warning.

  5. 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. 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 with NotFoundError: Failed to execute 'removeChild', which the Break it below reproduces.

  7. 7

    Awkward data needs a decision, not a crash. An empty list gives d3.max([]) = undefined and a scale with a NaN domain. A null gives NaN widths. 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

01

Which D3 modules are safe to call during a React render, and which are not? Why?

02

Why should keys be ids from the data rather than array positions? Give a chart example.

03

Why do you build scales with useMemo, and what would be wrong with relying on it for correctness?

04

What happens if D3 removes an element that React rendered, and how do you avoid it?

Practice

01

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.

02

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 key on 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 useMemo is for here, and why correctness must not depend on it.