Command Palette

Search for a command to run...

Hectal
PHASE 14Advanced ~25 min· topic 3 of 7

Topic 14.3

Responsive and Fast Charts in React

In one line

Measure the chart's box with a useSize hook, stop unrelated state from redrawing the chart, put thousands of marks on a canvas behind one ref, and know what the React Compiler changes.

Think of it like this

A picture frame in a shop window. The frame can be any size, so the picture is printed after the shop measures the frame. If someone repaints the window sign next to it, nobody reprints the picture.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

ResizeObserver
A browser feature that calls your function when an element's size changes.
layout effect
useLayoutEffect: an effect that runs before the browser paints, used for measuring.
memo
React.memo(Component): a wrapper that skips re-rendering when the props are the same as last time.
stable identity
The same object or function across renders, so a comparison with Object.is says "unchanged".
virtualising
Rendering only the items that are on screen and leaving empty space for the rest.
backing store
The real pixel grid of a canvas (its width and height attributes), which can differ from its size on screen.
React Compiler
A build-time tool that rewrites components so that React reuses their results when the inputs did not change.

Step by step

01Measure the box, draw after

useSize returns a ref to attach and the measured { width, height }. Until the first measurement the width is 0 and the dashboard shows "measuring…". When the observer fires, React re-renders the chart with the real width.

02Keep the hover away from the chart

The first example puts an unrelated hover counter on the dashboard and draws the same line twice: once in a plain component and once in a memo one. Three hovers re-render the plain chart three times and the memoised one never. Resizing the box changes its width prop, so both redraw once.

03Thousands of marks: one canvas

The second example reads the 10,000 trips of June and draws one 2 × 2 pixel square for each on a canvas. React renders one <canvas>. The drawing is an effect, and it clears first, so running it again gives the same picture.

Thousands of marks: one canvasdiagram
Rendering diagram…

04Let the compiler do the memoising (optional)

In a Vite project with @vitejs/plugin-react 6, the React Compiler runs through Babel. This was run for real on a small scratch copy of the Lumen project: three extra packages and a changed config.

vite.config.jswhole filejs
import { defineConfig } from "vite";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";

export default defineConfig({
  plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
});
terminal
$ npm install -D @rolldown/plugin-babel @babel/core babel-plugin-react-compiler && npx vite build
── expected output ──
(installed: @rolldown/plugin-babel 0.2.4, @babel/core 8.0.7, babel-plugin-react-compiler 1.0.0)
vite v8.3.4 building client environment for production...
transforming...
✓ 951 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 8.13 kB │ gzip: 2.84 kB
dist/assets/index-Cifu2SZ6.js 616.67 kB │ gzip: 185.07 kB
 
✓ built in 1.62s
Without the compiler the same scratch project built 949 modules into 614.64 kB (184.09 kB gzipped): the compiler adds memoisation code. The plugin's README also describes a native (Rust) compiler option and marks it experimental, so check it before you rely on it.

Try it yourself

  1. 1

    Make the memo useless on purpose

    In the first example change <MemoLine data={data} width={width} /> to <MemoLine data={[...data]} width={width} />. The second console line now says the memo chart re-rendered 3 times: a new array every render is a changed prop.

  2. 2

    Change the box width

    Change width: 320 in the dashboard's style to width: 240. The first line prints measured width: 240: the hook reported the new size and the chart was drawn at it. The last line still says 200, because the script resizes the box to 200 px itself.

  3. 3

    Draw fewer trips

    In the second example pass rows={rows.slice(0, 1000)}. The picture keeps its shape with a tenth of the marks, and the effect logs drew 1000 marks. A new array on every render would re-run the effect each time: keep the slice in useMemo.

Code & diagrams

A useSize hook, and a chart that ignores unrelated state New tab

Run it and change the box width by editing `style={{ width: 320 }}`: the chart follows. The three counts come from real renders. Try passing `data={[...data]}` to MemoLine: a new array each render defeats memo, and the memo count goes up with every hover.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

measured width: 320
3 hovers: plain chart re-rendered 3 times, memo chart 0 times
box resized to 200px -> svg width 200 | both charts re-rendered once more: true
10,000 trips on a canvas, one React element New tab

Minutes are whole numbers, so the marks line up in columns. The same 10,000 marks as SVG circles would be 10,000 DOM nodes; Topic 12.1 measures where that starts to hurt.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

trips: 10000
drew 10000 marks in one effect, no DOM nodes for them
canvas elements: 1 | svg elements: 0

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 before the size is known

In the first example change <PlainLine data={data} width={width} /> to <PlainLine data={data} width={width - 400} />, as if the margins were wider than the box.

terminal
$ Console
── what you'll see ──
Error: <svg> attribute width: A negative value is not valid. ("-80")
measured width: -80
3 hovers: plain chart re-rendered 3 times, memo chart 0 times
Error: <svg> attribute width: A negative value is not valid. ("-200")
box resized to 200px -> svg width -200 | both charts re-rendered once more: true
Copied from a real run. The first error comes from the 320 px box, the second from the resize to 200 px. The line after the first error is the example reading the svg's width back.

Break #2

Put the hover state next to the chart

Nothing to change: PlainLine in the first example is already a chart that sits under a parent with unrelated state. Read its count.

terminal
$ Console
── what you'll see ──
3 hovers: plain chart re-rendered 3 times, memo chart 0 times
Nothing breaks. The chart is simply recomputed (scales, 6 path points) on every mouse move.

Myth vs fact

Myth

Responsive means the SVG has width="100%".

Fact

That is true only if the SVG has a viewBox, and then the whole drawing, text included, scales like an image. Measuring the box and redrawing keeps text readable and lets the chart choose fewer ticks on a phone.

Myth

useMemo makes a chart fast.

Fact

It skips repeated work only when its inputs are unchanged. A chart whose data changes every second still does all the work every second.

Myth

A canvas chart cannot be used with React.

Fact

React renders one <canvas> element and an effect draws on it. What is hard is interaction (hit-testing) and accessibility, not the drawing.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    ResizeObserver reports contentRect and borderBoxSize, which are not always the same as clientWidth. Read entry.contentRect.width when your chart should match the content box and not include padding.

  • ▸

    A ResizeObserver callback runs before paint. Setting state inside it re-renders synchronously enough that the first frame already has the right size. If a callback changes the observed element's size in a way that triggers another observation in the same frame, the browser can log a ResizeObserver loop message. Keep the chart's size set by its parent's CSS, not by the chart.

  • ▸

    Passing width from state to a memo chart redraws on every pixel of a window drag. Round it (Math.round(width / 10) * 10) or debounce the observer if the redraw is heavy.

  • ▸

    For many small charts, content-visibility: auto on each card lets the browser skip layout and paint for off-screen ones without any React code. Check browser support for the browsers you target before relying on it.

Remember this

  1. 1

    The problem this solves: a chart needs its width in pixels to build scales, but React does not know the width: the browser decides it after layout. And a chart that redraws every time anything on the page changes (a mouse-move counter, a clock) wastes time and flickers.

  2. 2

    Measure with a ref and a ResizeObserver. useRef gives you the box, and new ResizeObserver(callback) calls you when its size changes (and once when you start observing). Store { width, height } in state and pass the width to the chart. Read the size in useLayoutEffect so the first measurement happens before the browser paints: otherwise the chart flashes at the wrong size.

  3. 3

    Draw nothing until you know the size. Before the first measurement, and in a box that is hidden (display: none in a closed tab, or a parent with zero width), the size is 0. A scale with range [0, -40] draws negative widths and prints SVG errors. Return a placeholder ("measuring…") when width is 0, and clamp a range that would be smaller than its margins.

  4. 4

    Two ways to be responsive. Topic 10.5 compared them. A viewBox that scales the whole drawing keeps one layout and needs no hook, but text shrinks with the chart. Measuring and redrawing keeps text at a readable size and lets you change the chart with the width (fewer ticks on a phone), at the price of a state change per resize.

  5. 5

    **memo stops redraws that nothing asked for.** When a parent re-renders, React re-renders all of its children, even if their props are the same. memo(Chart) compares props one by one with Object.is and skips the child when none changed. The trap is a prop that is a new object every time: data={rows.filter(...)}, options={{ ... }} or onBrush={() => ...} are all new values on each render and defeat memo. Make them stable with useMemo and useCallback, or move the changing state (the hover) down into the chart itself.

  6. 6

    Many small charts. A grid of 25 sparklines is fine. 2,000 is not: each one is a component with its own DOM nodes. Render only the ones on screen (virtualise the list: @tanstack/react-virtual, react-window, or the technique in the React course's Topic 16.9), and give the others a fixed-size placeholder so the scroll bar stays right.

  7. 7

    Canvas inside React. A <canvas> is one element, so React renders it once and never sees what is drawn on it. The ref gives you its context, and an effect draws, listing the data and the size as dependencies. Clear before each draw (an effect can run again), size the backing store for the screen's pixel density (Topic 11.4), and cancel any animation frame in the cleanup. What you give up: the marks are not elements, so hover, focus and screen readers need extra work (Topics 12.3 and 15.1).

  8. 8

    The React Compiler. It is a build step that adds memoisation to your components and values automatically wherever it can prove it safe, so a hover counter no longer redraws an untouched chart without you writing memo or useMemo. It does not make render work cheaper by itself: a chart that builds 5,000 path points every render is still slow when it does re-render. Keep useMemo where you need a stable identity for an effect's dependency list.

Explain it without notes

01

Why does a useSize hook measure in useLayoutEffect and return a width of 0 first?

02

A dashboard parent re-renders on every mouse move. Give two ways to stop the chart inside it from redrawing, and one way each can fail.

03

When would you draw a React chart on a canvas instead of SVG, and what do you lose?

04

What does the React Compiler change for chart components, and what does it not change?

Practice

01

Write useSize() so that it returns [ref, { width, height }], ignores a change smaller than 1 pixel, and disconnects the observer on cleanup. Where would you read the first size, and why?

02

A line chart takes data and onHover. The parent writes <Line data={rows} onHover={(d) => setHovered(d)} /> and the chart still redraws on every hover. What is wrong, and what is the fix?

Trade-offs

  • ↔

    Redrawing at the measured size keeps text sharp and lets you change the chart per width; a viewBox is simpler and needs no hook. Many dashboards use viewBox for small charts and useSize for the main one.

  • ↔

    memo and the compiler save redraws but add a rule (keep props stable) that every teammate must follow. The compiler moves the rule into the build, at the cost of a build step and a few more kilobytes.

Done when you can

  • I can write a useSize hook with ResizeObserver that cleans up after itself.

  • I can draw a placeholder when the box has zero width, instead of negative widths and NaN.

  • I can make a chart skip redraws caused by unrelated state, and name what defeats memo.

  • I can draw 10,000 marks on a canvas from a React effect.

  • I can say what the React Compiler does and does not change.