Charts in React: Who Renders the Pixels

The chart looked perfect in the demo. Clean line, smooth animation, a tooltip that felt expensive. Then real data showed up — forty thousand points instead of the twenty I'd tested with — and the tab froze solid every time you hovered.
That's the moment you learn charts aren't a UI problem. They're a rendering problem wearing a UI costume.
"Just use a chart library" is the advice everyone gives, and it's not wrong, exactly. It's just hiding the actual decision. There are a dozen chart libraries and they are not interchangeable. Picking one commits you to a rendering model, a customization ceiling, and a performance profile — and you usually discover all three at the worst possible time, which is after the design changes or the data grows.
So this article is about how I actually choose, and the question underneath the choice is the same one that decided the UI library article and the forms article: who owns the thing being rendered — the library, or me?
The Two Questions That Decide Everything
Before any library names, two questions do most of the sorting.
How much do you need to control the visuals? A dashboard with standard bar and line charts is a different problem from a bespoke visualization no chart library has a preset for. The first wants defaults. The second wants primitives.
How big is your data, and how interactive is it? A hundred points rendered as SVG is nothing. A hundred thousand points, hovering and zooming, is where SVG dies and you need Canvas or WebGL. This one question quietly eliminates half the options before you've read their docs.
Everything below is really just these two axes: control vs done-for-you, and data size vs interactivity. Hold them in your head and the landscape stops looking like a dozen equivalent choices.
Recharts: The Sensible Default
For the majority of what people actually build — dashboards, admin panels, the CRM-shaped screens this series keeps returning to — Recharts is where I start. It's React-native and composable: charts are components, and you build them the way you build the rest of your UI.
import { LineChart, Line, XAxis, YAxis, Tooltip } from 'recharts'; function RevenueChart({ data }: { data: MonthlyRevenue[] }) { return ( <LineChart width={640} height={320} data={data}> <XAxis dataKey="month" /> <YAxis /> <Tooltip /> <Line type="monotone" dataKey="revenue" stroke="#22c55e" /> </LineChart> ); }
Look at what that is: no imperative setup, no lifecycle wiring, no D3 selections. Axes are components. The line is a component. You compose a chart the way you compose a form or a page, and it fits a React codebase without friction. For standard chart types it's readable, quick, and easy to hand to another developer.
The ceiling is real, though, and worth naming. Recharts is great until your design asks for something outside its component vocabulary — a custom axis behavior, an unusual interaction, a shape it doesn't ship. Then you're either fighting its internals or reaching for something lower-level. For 80% of dashboards that ceiling is far above what you need. For the other 20%, you feel it early.
There are close siblings here — Nivo, Victory, react-chartjs-2 — and I won't pretend to rank them precisely. Nivo's defaults are beautiful and it has a Canvas escape hatch for larger datasets. Chart.js (via react-chartjs-2) renders to Canvas out of the box, which helps with volume but makes it feel less React-native. They cluster in the same place: high-level, done-for-you, great until the design goes off-script.
visx: Primitives, Not Charts
When the design does go off-script — when you're building a visualization rather than picking a chart — visx is what I reach for.
visx (from Airbnb) is a set of low-level primitives built on D3's math, wrapped so React owns the rendering. D3 does the scales, the shapes, the layout calculations; React draws the actual SVG. You don't get a <BarChart>. You get scales, axes, shapes, gradients — the pieces — and you assemble the exact chart you want.
import { scaleLinear } from '@visx/scale'; import { Bar } from '@visx/shape'; const xScale = scaleLinear({ domain: [0, maxValue], range: [0, width] }); function CustomBars({ data }: { data: Point[] }) { return data.map((d) => ( <Bar key={d.id} x={0} y={yScale(d.label)} width={xScale(d.value)} height={18} /> )); }
This is the same move as Radix and shadcn/ui from the earlier articles: the library gives you behavior and math, and hands the pixels back to you. The cost is honest — you write more, you think about scales and margins yourself, and there's no "just drop in a chart." The payoff is that there's no ceiling. If you can describe it, you can draw it, and React stays in control of every element.
I don't reach for visx for a revenue line chart. That would be building a table saw to cut one board. I reach for it when the visualization is the product feature, not a readout beside it.
Raw D3: Powerful, and at Odds with React
D3 is the foundation under most of this, and you can absolutely use it directly. I mostly don't, and the reason is architectural, not a knock on D3.
D3 wants to own the DOM. Its whole model is selecting elements and mutating them imperatively — enter, update, exit. React also wants to own the DOM, through its virtual model. Point them at the same nodes and you get two systems fighting over who's in charge, which shows up as subtle bugs where React re-renders away what D3 just drew, or vice versa.
The clean pattern is to split the roles: let D3 do the math, let React do the rendering. Use D3 for scales, layouts, and path generators — pure functions that take data and return numbers or SVG path strings — and let React turn those into elements. That's exactly what visx formalizes, which is why I'd rather use visx than wire that boundary by hand. If you do reach for raw D3 inside React, keep it to the calculation layer and let React render. The moment D3 starts calling .append() on nodes React manages, you've bought yourself a class of bugs that's genuinely hard to reason about.
The Thing Nobody Warns You About: SVG vs Canvas
Here's the constraint that ambushed me in the opening story, and it's independent of which library you pick.
Most chart libraries render to SVG, and SVG is wonderful — every point is a DOM node you can style, inspect, and attach handlers to. Until there are too many of them. Ten thousand DOM nodes is a slow page. Fifty thousand is a frozen one. The browser simply wasn't built to keep that many elements interactive.
Past that threshold you need Canvas (one element, drawn imperatively, no per-point DOM) or WebGL for the truly massive. This changes what you're allowed to pick. If you know up front you're plotting a hundred thousand time-series points with hover and zoom, that fact chooses your library before any aesthetic preference does — you filter to the ones with a Canvas renderer and decide from there. Choosing a beautiful SVG library and then discovering the data size is how you end up rewriting the chart layer under deadline.
So I've learned to ask the data-size question first, not last. It's the least glamorous input and the most decisive one.
How I Actually Decide
Strip it down and it's short:
- Standard dashboard charts, reasonable data size → Recharts. React-native, composable, done. This is most cases.
- Beautiful defaults, occasionally large data → Nivo, for its presets and Canvas option.
- A custom visualization that's a real product feature → visx. Own the pixels, no ceiling.
- Genuinely huge datasets, heavy interaction → a Canvas/WebGL renderer, and let data size lead the choice.
- Raw D3 → for the math, behind a React rendering layer. Rarely for the DOM directly.
For the Playbook, Recharts is the default and visx is the escape hatch — the same shape as every other choice in this series. Start with the composable, React-native option that agrees with the rest of the stack; drop to primitives only when the design demands it.
But I want to end where the real difficulty lives, because it isn't the library. The hard part of a chart is never the rendering — it's the data. Getting your series into the exact shape the chart wants, aggregating and bucketing correctly, choosing the visualization that answers the actual question instead of the one that looks impressive. A library renders whatever you hand it. It has no opinion on whether the chart is honest, or clear, or answering the right question. That part is yours, and no dependency will do it for you.
Data going in, data coming out — we've covered both. The next article turns to something every real app needs and everyone underestimates: authentication. Rolling your own versus buying it, and where that decision quietly reshapes your whole architecture.
If you've hit the SVG performance wall, or fought D3 and React over the same DOM, I'd like to hear how it went — those are the war stories where the interesting trade-offs actually surface, and I'm always collecting the ones I haven't run into yet.