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.issays "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
widthandheightattributes), 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.
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.
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()] })],
});Try it yourself
- 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
Change the box width
Change
width: 320in the dashboard'sstyletowidth: 240. The first line printsmeasured 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
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 logsdrew 1000 marks. A new array on every render would re-run the effect each time: keep the slice inuseMemo.
Code & diagrams
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
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: trueMinutes 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
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: 0Break 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.
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.
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.
- ▸
ResizeObserverreportscontentRectandborderBoxSize, which are not always the same asclientWidth. Readentry.contentRect.widthwhen your chart should match the content box and not include padding. - ▸
A
ResizeObservercallback 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 aResizeObserver loopmessage. Keep the chart's size set by its parent's CSS, not by the chart. - ▸
Passing
widthfrom state to amemochart 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: autoon 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
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
Measure with a ref and a ResizeObserver.
useRefgives you the box, andnew 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 inuseLayoutEffectso the first measurement happens before the browser paints: otherwise the chart flashes at the wrong size. - 3
Draw nothing until you know the size. Before the first measurement, and in a box that is hidden (
display: nonein 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…") whenwidthis 0, and clamp a range that would be smaller than its margins. - 4
Two ways to be responsive. Topic 10.5 compared them. A
viewBoxthat 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
**
memostops 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 withObject.isand skips the child when none changed. The trap is a prop that is a new object every time:data={rows.filter(...)},options={{ ... }}oronBrush={() => ...}are all new values on each render and defeatmemo. Make them stable withuseMemoanduseCallback, or move the changing state (the hover) down into the chart itself. - 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
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
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
memooruseMemo. 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. KeepuseMemowhere you need a stable identity for an effect's dependency list.
Explain it without notes
Why does a useSize hook measure in useLayoutEffect and return a width of 0 first?
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.
When would you draw a React chart on a canvas instead of SVG, and what do you lose?
What does the React Compiler change for chart components, and what does it not change?
Practice
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?
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
viewBoxis simpler and needs no hook. Many dashboards useviewBoxfor small charts anduseSizefor the main one. - ↔
memoand 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
useSizehook withResizeObserverthat 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.