Skip to content

Charts and Data Visualization in React

Aman Kumar Singh7 min read
Part 37 of 50From the React & Next.js Complete Guide series
Charts and Data Visualization in React — article by Aman Kumar Singh

In Animations with Framer Motion, I covered how motion communicates state change: something moved, something appeared, something is loading. Charts are a different problem. They're the primary way a user understands a number, not decoration around an interaction. Get a chart wrong and the user walks away with a false mental model of their own data, a worse outcome than an ugly chart.

This is part of the React & Next.js Complete Guide series, which builds up from React Fundamentals for Professionals toward production Next.js apps. Charts sit at an odd intersection in that series: they're rendering-heavy like animation work, but the correctness bar is closer to a form, because a mislabeled axis or a truncated data set is a bug, not a style choice.

Most teams reach for a charting library on day one and never revisit that decision. Sometimes that's the right call. Sometimes it's overkill. I want to walk through both cases, along with the production details, resizing, accessibility, large datasets, that separate a chart that looks fine in a demo from one that survives a real dashboard.

Choosing a charting approach: library, primitive, or custom SVG

There are three realistic options for React charts, and the right one depends on how much control you need over rendering versus how much time you want to spend building chart infrastructure.

A high-level library like Recharts or Chart.js (via a React wrapper) gets you a working line, bar, or pie chart in a few lines of JSX, with sensible defaults for axes, tooltips, and legends. This is the correct starting point for most SaaS dashboards. You're shipping a product, not a charting engine, and a library already battle-tested against browser quirks, resize behavior, and touch interactions saves real time.

import {
  LineChart,
  Line,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
} from "recharts";

type RevenuePoint = {
  date: string;
  revenue: number;
};

function RevenueChart({ data }: { data: RevenuePoint[] }) {
  return (
    <ResponsiveContainer width="100%" height={320}>
      <LineChart data={data} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
        <CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
        <XAxis dataKey="date" tickLine={false} axisLine={false} />
        <YAxis
          tickLine={false}
          axisLine={false}
          tickFormatter={(value: number) => `$${value.toLocaleString()}`}
        />
        <Tooltip
          formatter={(value: number) => [`$${value.toLocaleString()}`, "Revenue"]}
        />
        <Line
          type="monotone"
          dataKey="revenue"
          stroke="#2563eb"
          strokeWidth={2}
          dot={false}
        />
      </LineChart>
    </ResponsiveContainer>
  );
}

A primitive-level library like Visx (from Airbnb) or D3 gives you the building blocks, scales, axes, shape generators, without a pre-built chart component. Reach for this when the design needs something a standard library can't express cleanly: a custom radial layout, a chart blending multiple data types in one view, or an interaction model (brushing, zooming into a time range) that off-the-shelf tooltips don't cover. The cost is real: you're now maintaining scale math and SVG generation yourself.

Fully custom SVG or Canvas is rarely the right call, usually reserved for extreme performance requirements (tens of thousands of points, real-time updates) or a genuinely novel visualization with no existing chart type to map to. It's the option with the most flexibility and the most long-term maintenance cost. Most dashboards never need it.

The mistake I see most often is skipping straight to D3 or custom SVG because it feels more impressive, then rebuilding tooltip positioning, responsive resizing, and accessible labels by hand, work a maintained library already did correctly. Defer that complexity until the product actually asks for it.

Making charts respond to their container, not the viewport

A chart that's sized against window.innerWidth breaks the moment it's dropped into a sidebar, a modal, or a resizable panel. Charts need to size against their parent container, and most libraries solve this with a ResizeObserver-backed wrapper, ResponsiveContainer in Recharts, ParentSize in Visx.

The subtlety worth knowing: ResizeObserver fires on the container's actual box size, so a chart rendered inside a display: none element, a hidden tab panel, or a collapsed accordion gets a zero-width measurement and renders nothing until it becomes visible and the observer fires again. If your dashboard has tabbed chart views, don't render every tab's chart eagerly and hide it with CSS. Mount the chart only when its tab becomes active, or explicitly re-trigger a resize measurement when visibility changes.

import { useEffect, useState } from "react";

function useIsVisible(ref: React.RefObject<HTMLElement>) {
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new IntersectionObserver(
      ([entry]) => setIsVisible(entry.isIntersecting),
      { threshold: 0.01 }
    );
    observer.observe(element);

    return () => observer.disconnect();
  }, [ref]);

  return isVisible;
}

Mounting the chart only when this hook reports visibility avoids the zero-width render entirely, and it has a secondary benefit: you're not paying the layout and paint cost of six charts on a dashboard the user hasn't scrolled to yet.

Large datasets: aggregate before you render, not after

A time series chart rendering ten thousand raw points is a performance problem disguised as a data problem. The browser has to lay out and paint that many SVG path segments (or Canvas draw calls) on every resize and every re-render. And a line chart at that density is usually visually indistinguishable from one built on a downsampled series, since the pixels available to render into are far fewer than the data points.

The fix is aggregation before the data reaches the chart component, not a chart-level "just render fewer points" setting. Decide the aggregation strategy based on what the chart is for:

  • For a time series over a long range, bucket by time (hourly, daily, weekly) at the query layer, in SQL or your API, rather than shipping raw rows and downsampling in JavaScript.
  • For a scatter plot with genuinely dense data, consider Canvas instead of SVG. SVG creates a DOM node per shape and becomes the bottleneck well before Canvas does, since Canvas is a single element regardless of point count.
  • If users need to drill into raw data, keep the chart aggregated and let a separate table or export handle raw rows. Don't make one chart do both jobs.
select
  date_trunc('day', created_at) as day,
  sum(amount_cents) as revenue_cents
from orders
where created_at >= now() - interval '90 days'
group by 1
order by 1;

Pushing this bucketing into the query means the API response is already chart-sized, the frontend never has to reason about "how many points is too many," and the query can use an index on created_at instead of fetching everything and throwing most of it away.

Accessibility and the tooltip trap

Charts are one of the easier places to ship an interface that's entirely unusable without a mouse. A tooltip that only appears on hover, with no keyboard equivalent and no text alternative, leaves a screen reader user or a keyboard-only user with a decorative image and nothing else.

A few things are worth building in from the start rather than retrofitting:

  • Give the chart container a text description, either a visually hidden summary (sr-only in Tailwind terms) or an adjacent data table mirroring what the chart shows. This also gives search engines and automated tests something concrete to assert against.
  • If the chart supports keyboard focus on individual points or bars, expose the tooltip content through aria-live too, so a screen reader announces the value change instead of leaving it as a visual-only popup.
  • Don't rely on color alone to distinguish series. Pair color with shape or pattern differences in the legend, since color-blind users and grayscale printouts both lose a color-only signal.

None of this is exotic, but it's easy to skip because a chart "looks done" once the data renders correctly. The gap only shows up when someone tries to use it without a mouse or without full color vision.

Server-rendering charts in Next.js without a hydration mismatch

Most charting libraries, Recharts included, measure the DOM to size themselves, which means they need a browser environment and can't meaningfully render on the server. Import a chart component directly into a Server Component or a page that server-renders by default, and you'll get a build error or a hydration mismatch, since the server has no layout information to draw against.

The straightforward fix is marking the chart as a Client Component and, if it depends on ResizeObserver or window, disabling SSR for it entirely:

"use client";

import dynamic from "next/dynamic";

const RevenueChart = dynamic(() => import("./revenue-chart"), {
  ssr: false,
  loading: () => <div className="h-80 animate-pulse rounded-lg bg-gray-100" />,
});

export function DashboardRevenueSection({ data }: { data: RevenuePoint[] }) {
  return <RevenueChart data={data} />;
}

The tradeoff is that the chart's initial paint waits for client-side JavaScript to load and execute, which shows up as layout shift if you don't reserve space for it. The loading fallback with a fixed height handles that: the skeleton occupies the same footprint the chart will, so nothing jumps when the real chart mounts. Deferring to client rendering here is the right call, because a chart genuinely can't be meaningfully pre-rendered without the browser's layout engine.

Key takeaways

  • Start with a high-level charting library (Recharts, Chart.js) for standard dashboard charts. Reach for Visx or D3 only when the design needs an interaction or layout the library can't express, and reach for custom SVG/Canvas only for extreme performance or genuinely novel visualizations.
  • Size charts against their container with a `ResizeObserver`-based wrapper, and be deliberate about rendering charts inside hidden tabs or collapsed panels, since a zero-width container produces an empty chart.
  • Aggregate large time series at the query layer, not in the browser. Ship the chart pre-bucketed instead of downsampling raw rows client-side.
  • Build in a text alternative, keyboard-accessible tooltips, and non-color-dependent series distinction from the start, since retrofitting chart accessibility is harder than designing for it upfront.
  • Charts that depend on browser layout measurement need to be Client Components in Next.js, often with SSR disabled and a fixed-height loading skeleton to avoid layout shift.

Frequently asked questions

Should I use Recharts, Chart.js, or Visx for a typical SaaS dashboard?

Recharts is usually the better default for React specifically, since its API is component-based and composes naturally with the rest of a React codebase. Chart.js suits teams that already have Chart.js configurations from a non-React project. Reach for Visx only once you need layout control Recharts doesn't expose.

Why does my chart render at zero width the first time it mounts?

This usually means the container had no measurable width when `ResizeObserver` first fired, often because the chart sits inside a hidden tab, a collapsed accordion, or a flex container that hasn't finished its own layout pass. Mount the chart only when its container is actually visible, or trigger a manual resize once visibility changes.

How many data points is too many for a line or bar chart?

There's no fixed number since it depends on chart width in pixels and rendering technology, but if a raw dataset regularly runs into the thousands of points, that's a signal to aggregate at the query layer rather than tune chart-level rendering settings. A screen a few hundred pixels wide can't distinguish more points than it has pixels for anyway.

Do charts need to be Client Components in the Next.js App Router?

Yes, in practice, since most charting libraries measure the DOM to size themselves and can't run during server rendering. Mark the component with `"use client"` and disable SSR via `next/dynamic` if it touches `ResizeObserver` or `window` directly.

What's the minimum accessibility bar for a chart in a production dashboard?

At minimum: a text alternative (a visually hidden summary or an adjacent data table), tooltips that don't depend exclusively on hover, and series distinguishable by more than color alone. This covers most real accessibility failures without a full custom accessible-charting implementation.

Is it ever worth building a chart with raw SVG instead of a library?

Occasionally, mostly for custom visualizations with no matching standard chart type, or where you need tight control over animation and rendering at very high data volumes. For most dashboard charts, line, bar, area, pie, this costs more in maintenance than the flexibility is worth.

Related articles

A React and Next.js Production Checklist — Aman Kumar Singh
Core Web Vitals for React Apps — Aman Kumar Singh
Image and Asset Optimization — Aman Kumar Singh

Explore more on

Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.