Data Visualization with Recharts: Dashboard Patterns

Build production-ready dashboards with Recharts in React. Covers bar, line, area, pie charts, responsive containers, custom tooltips, real-time data, and dark mode support.

E
ECOSIRE Research and Development Team
|2026年3月19日7 分で読める1.5k 語数|

Recharts によるデータ視覚化: ダッシュボード パターン

優れたダッシュボードとは、表内の単なる数字ではありません。傾向、異常、機会を一目で明らかにする視覚的なパターンです。 Recharts は、React エコシステムで最も広く使用されているグラフ作成ライブラリです。D3.js の基礎に基づいて構築されており、完全に TypeScript で型指定されており、すぐに使用できるコンポーザブルで応答性の高いライブラリです。 SVG の複雑さを処理できるため、データのストーリーに集中できます。

このガイドでは、ダーク モードのサポート、アクセス可能なカラー パレット、レスポンシブ レイアウトを備えた、本番ダッシュボードの Recharts パターン (収益傾向、コンバージョン ファネル、時系列指標、地理的分布、リアルタイム ストリーミング データ) について説明します。

重要なポイント

  • チャートは常に width="100%" と固定の height を使用して ResponsiveContainer でラップします。ピクセル幅をハードコーディングしないでください。
  • デフォルトの代わりに CustomTooltip コンポーネントを使用します - 書式設定とスタイルを完全に制御します
  • Recharts formatter コールバック パラメーターは決して明示的に入力しないでください。変換するには Number(value) を使用してください。
  • ダーク モードには動的なカラー値が必要です。ハードコードされた 16 進カラーではなく、CSS 変数から読み取られます。
  • 時系列データの場合、X 軸で tickFormatter を使用してタイムスタンプを一貫してフォーマットします
  • アクセシビリティ: aria-label をグラフ コンテナーに追加し、凡例項目で tabIndex を使用します。
  • パフォーマンス: useMemo を使用してチャート データをメモ化 — 親レンダリングごとに再チャートを再レンダリングします
  • リアルタイム データの場合、データ配列参照を更新します — Recharts は参照によって変更を検出します

セットアップ

pnpm add recharts
pnpm add -D @types/recharts  # Usually not needed — recharts ships its own types

Recharts 2.x には TypeScript 型が同梱されています。個別の @types パッケージは必要ありません。


収益エリアグラフ

// src/components/charts/revenue-area-chart.tsx
'use client';

import { useMemo } from 'react';
import {
  AreaChart, Area, XAxis, YAxis, CartesianGrid,
  Tooltip, Legend, ResponsiveContainer,
} from 'recharts';
import { format, parseISO } from 'date-fns';

interface RevenueDataPoint {
  date: string;       // ISO date string
  revenue: number;
  refunds: number;
  net: number;
}

interface CustomTooltipProps {
  active?: boolean;
  payload?: Array<{ name: string; value: number; color: string }>;
  label?: string;
}

function CustomTooltip({ active, payload, label }: CustomTooltipProps) {
  if (!active || !payload || !label) return null;

  return (
    <div className="rounded-lg border bg-background p-3 shadow-md">
      <p className="mb-2 text-sm font-medium text-muted-foreground">
        {format(parseISO(label), 'MMM d, yyyy')}
      </p>
      {payload.map((entry) => (
        <div key={entry.name} className="flex items-center gap-2 text-sm">
          <span
            className="inline-block h-2 w-2 rounded-full"
            style={{ backgroundColor: entry.color }}
          />
          <span className="text-muted-foreground">{entry.name}:</span>
          <span className="font-medium">
            {new Intl.NumberFormat('en-US', {
              style: 'currency',
              currency: 'USD',
              maximumFractionDigits: 0,
            }).format(entry.value)}
          </span>
        </div>
      ))}
    </div>
  );
}

interface RevenueAreaChartProps {
  data: RevenueDataPoint[];
  loading?: boolean;
}

export function RevenueAreaChart({ data, loading }: RevenueAreaChartProps) {
  // CRITICAL: memoize chart data — Recharts re-renders on every reference change
  const chartData = useMemo(() => data, [data]);

  if (loading) {
    return <div className="h-80 animate-pulse rounded-lg bg-muted" />;
  }

  return (
    <div role="img" aria-label="Monthly revenue area chart">
      <ResponsiveContainer width="100%" height={320}>
        <AreaChart data={chartData} margin={{ top: 10, right: 30, left: 0, bottom: 0 }}>
          <defs>
            <linearGradient id="colorRevenue" x1="0" y1="0" x2="0" y2="1">
              <stop offset="5%"  stopColor="#f59e0b" stopOpacity={0.3} />
              <stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
            </linearGradient>
            <linearGradient id="colorNet" x1="0" y1="0" x2="0" y2="1">
              <stop offset="5%"  stopColor="#22c55e" stopOpacity={0.3} />
              <stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
            </linearGradient>
          </defs>

          <CartesianGrid
            strokeDasharray="3 3"
            stroke="hsl(var(--border))"
            vertical={false}
          />

          <XAxis
            dataKey="date"
            tickFormatter={(value: string) => format(parseISO(value), 'MMM d')}
            tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
            axisLine={false}
            tickLine={false}
          />

          <YAxis
            tickFormatter={(value) => `$${(Number(value) / 1000).toFixed(0)}k`}
            tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
            axisLine={false}
            tickLine={false}
            width={60}
          />

          <Tooltip content={<CustomTooltip />} />

          <Legend
            wrapperStyle={{ fontSize: '14px', paddingTop: '16px' }}
            formatter={(value: string) =>
              <span className="text-foreground">{value}</span>
            }
          />

          <Area
            type="monotone"
            dataKey="revenue"
            name="Gross Revenue"
            stroke="#f59e0b"
            strokeWidth={2}
            fill="url(#colorRevenue)"
          />
          <Area
            type="monotone"
            dataKey="net"
            name="Net Revenue"
            stroke="#22c55e"
            strokeWidth={2}
            fill="url(#colorNet)"
          />
        </AreaChart>
      </ResponsiveContainer>
    </div>
  );
}

棒グラフ: 月ごとの比較

// src/components/charts/monthly-bar-chart.tsx
'use client';

import {
  BarChart, Bar, XAxis, YAxis, CartesianGrid,
  Tooltip, Legend, ResponsiveContainer, Cell,
} from 'recharts';

interface MonthlyData {
  month: string;
  current: number;
  previous: number;
  target: number;
}

function CustomBarTooltip({ active, payload, label }: any) {
  if (!active || !payload) return null;

  return (
    <div className="rounded-lg border bg-background p-3 shadow-md">
      <p className="mb-2 font-medium">{label}</p>
      {payload.map((entry: { name: string; value: number; fill: string }) => (
        <div key={entry.name} className="flex items-center gap-2 text-sm">
          <span
            className="inline-block h-2 w-2 rounded-full"
            style={{ backgroundColor: entry.fill }}
          />
          <span className="text-muted-foreground">{entry.name}:</span>
          <span className="font-medium">
            {new Intl.NumberFormat('en-US', {
              style: 'currency',
              currency: 'USD',
              maximumFractionDigits: 0,
            }).format(entry.value)}
          </span>
        </div>
      ))}
    </div>
  );
}

export function MonthlyBarChart({ data }: { data: MonthlyData[] }) {
  return (
    <ResponsiveContainer width="100%" height={300}>
      <BarChart data={data} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
        <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
        <XAxis
          dataKey="month"
          tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
          axisLine={false}
          tickLine={false}
        />
        <YAxis
          tickFormatter={(value) => `$${(Number(value) / 1000).toFixed(0)}k`}
          tick={{ fontSize: 12, fill: 'hsl(var(--muted-foreground))' }}
          axisLine={false}
          tickLine={false}
        />
        <Tooltip content={<CustomBarTooltip />} />
        <Legend />
        <Bar dataKey="previous" name="Previous Year" fill="hsl(var(--muted))" radius={[4, 4, 0, 0]} />
        <Bar dataKey="current"  name="Current Year"  fill="#f59e0b"            radius={[4, 4, 0, 0]} />
        <Bar dataKey="target"   name="Target"        fill="#22c55e" opacity={0.4} radius={[4, 4, 0, 0]} />
      </BarChart>
    </ResponsiveContainer>
  );
}

ドーナツ / 円グラフ

// src/components/charts/platform-pie-chart.tsx
'use client';

import { useState } from 'react';
import {
  PieChart, Pie, Cell, Sector,
  ResponsiveContainer, Legend, Tooltip,
} from 'recharts';

interface PieDataPoint {
  name: string;
  value: number;
  color: string;
}

const PLATFORM_COLORS: Record<string, string> = {
  'Odoo':         '#714b67',
  'Shopify':      '#96bf48',
  'GoHighLevel':  '#f59e0b',
  'Power BI':     '#f2c811',
  'OpenClaw':     '#0ea5e9',
  'Other':        '#94a3b8',
};

function renderActiveShape(props: any) {
  const {
    cx, cy, innerRadius, outerRadius, startAngle, endAngle,
    fill, payload, percent, value,
  } = props;

  return (
    <g>
      <text x={cx} y={cy - 12} textAnchor="middle" className="text-base font-bold fill-foreground">
        {payload.name}
      </text>
      <text x={cx} y={cy + 12} textAnchor="middle" className="text-sm fill-muted-foreground">
        {`${(percent * 100).toFixed(1)}%`}
      </text>
      <text x={cx} y={cy + 32} textAnchor="middle" className="text-sm fill-muted-foreground">
        {`$${(Number(value) / 1000).toFixed(0)}k`}
      </text>
      <Sector
        cx={cx} cy={cy}
        innerRadius={innerRadius}
        outerRadius={outerRadius + 8}
        startAngle={startAngle}
        endAngle={endAngle}
        fill={fill}
      />
      <Sector
        cx={cx} cy={cy}
        innerRadius={outerRadius + 10}
        outerRadius={outerRadius + 12}
        startAngle={startAngle}
        endAngle={endAngle}
        fill={fill}
      />
    </g>
  );
}

export function PlatformPieChart({ data }: { data: PieDataPoint[] }) {
  const [activeIndex, setActiveIndex] = useState(0);

  const chartData = data.map((item) => ({
    ...item,
    color: PLATFORM_COLORS[item.name] ?? '#94a3b8',
  }));

  return (
    <ResponsiveContainer width="100%" height={300}>
      <PieChart>
        <Pie
          activeIndex={activeIndex}
          activeShape={renderActiveShape}
          data={chartData}
          cx="50%"
          cy="50%"
          innerRadius={70}
          outerRadius={100}
          dataKey="value"
          onMouseEnter={(_, index) => setActiveIndex(index)}
        >
          {chartData.map((entry, index) => (
            <Cell key={`cell-${index}`} fill={entry.color} />
          ))}
        </Pie>
        <Legend
          formatter={(value: string) =>
            <span className="text-sm text-foreground">{value}</span>
          }
        />
      </PieChart>
    </ResponsiveContainer>
  );
}

リアルタイム折れ線グラフ

// src/components/charts/realtime-chart.tsx
'use client';

import { useState, useEffect, useRef } from 'react';
import {
  LineChart, Line, XAxis, YAxis, CartesianGrid,
  Tooltip, ResponsiveContainer, ReferenceLine,
} from 'recharts';

interface MetricPoint {
  time: string;
  requests: number;
  errors: number;
  p95: number;
}

const MAX_DATA_POINTS = 60; // 60 seconds of data

export function RealtimeChart({ endpoint }: { endpoint: string }) {
  const [data, setData] = useState<MetricPoint[]>([]);
  const intervalRef = useRef<ReturnType<typeof setInterval>>();

  useEffect(() => {
    const fetchMetrics = async () => {
      try {
        const response = await fetch(endpoint);
        const point: MetricPoint = await response.json();

        setData((prev) => {
          const updated = [
            ...prev,
            { ...point, time: new Date().toLocaleTimeString() },
          ];
          // Keep only the last MAX_DATA_POINTS — sliding window
          return updated.slice(-MAX_DATA_POINTS);
        });
      } catch {
        // Metrics fetch failure is non-critical — log but do not crash
      }
    };

    fetchMetrics();
    intervalRef.current = setInterval(fetchMetrics, 1000);

    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current);
      }
    };
  }, [endpoint]);

  return (
    <ResponsiveContainer width="100%" height={200}>
      <LineChart data={data}>
        <CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
        <XAxis
          dataKey="time"
          tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
          interval="preserveStartEnd"
        />
        <YAxis
          tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
          axisLine={false}
        />
        <Tooltip />
        {/* Reference line for SLO threshold */}
        <ReferenceLine
          y={200}
          label={{ value: 'SLO 200ms', position: 'right', fontSize: 10 }}
          stroke="#ef4444"
          strokeDasharray="4 4"
        />
        <Line
          type="monotone"
          dataKey="p95"
          name="P95 Latency (ms)"
          stroke="#f59e0b"
          strokeWidth={2}
          dot={false}
          isAnimationActive={false} // Disable animation for real-time data
        />
        <Line
          type="monotone"
          dataKey="errors"
          name="Errors/s"
          stroke="#ef4444"
          strokeWidth={2}
          dot={false}
          isAnimationActive={false}
        />
      </LineChart>
    </ResponsiveContainer>
  );
}

統計カードを含むダッシュボードのレイアウト

// src/components/dashboard/metrics-dashboard.tsx
'use client';

import { useQuery } from '@tanstack/react-query';
import { apiFetch } from '@/lib/api';
import { RevenueAreaChart } from './revenue-area-chart';
import { MonthlyBarChart } from './monthly-bar-chart';
import { PlatformPieChart } from './platform-pie-chart';

export function MetricsDashboard() {
  const { data: metrics, isLoading } = useQuery({
    queryKey: ['dashboard-metrics'],
    queryFn: () => apiFetch<DashboardMetrics>('/analytics/dashboard'),
    refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
  });

  return (
    <div className="space-y-6">
      {/* KPI Cards */}
      <div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
        {[
          { label: 'MRR', value: metrics?.mrr, format: 'currency', trend: '+12%' },
          { label: 'ARR',  value: metrics?.arr,  format: 'currency', trend: '+8%'  },
          { label: 'Customers', value: metrics?.customers, format: 'number', trend: '+23' },
          { label: 'Churn', value: metrics?.churnRate, format: 'percent', trend: '-0.3%' },
        ].map(({ label, value, format, trend }) => (
          <div key={label} className="rounded-lg border bg-card p-4">
            <p className="text-sm text-muted-foreground">{label}</p>
            <p className="mt-1 text-2xl font-bold">
              {isLoading ? (
                <span className="inline-block h-8 w-24 animate-pulse rounded bg-muted" />
              ) : (
                formatMetric(value, format)
              )}
            </p>
            <p className="mt-1 text-sm text-green-500">{trend} this month</p>
          </div>
        ))}
      </div>

      {/* Charts */}
      <div className="grid gap-6 lg:grid-cols-3">
        <div className="lg:col-span-2 rounded-lg border bg-card p-6">
          <h3 className="mb-4 text-lg font-semibold">Revenue Trend</h3>
          <RevenueAreaChart data={metrics?.revenueByDay ?? []} loading={isLoading} />
        </div>
        <div className="rounded-lg border bg-card p-6">
          <h3 className="mb-4 text-lg font-semibold">Revenue by Platform</h3>
          <PlatformPieChart data={metrics?.revenueByPlatform ?? []} />
        </div>
      </div>

      <div className="rounded-lg border bg-card p-6">
        <h3 className="mb-4 text-lg font-semibold">Monthly Comparison</h3>
        <MonthlyBarChart data={metrics?.monthlyComparison ?? []} />
      </div>
    </div>
  );
}

function formatMetric(value: number | undefined, format: string): string {
  if (value === undefined) return '—';
  const fmt = new Intl.NumberFormat('en-US');
  if (format === 'currency') return `$${fmt.format(value)}`;
  if (format === 'percent') return `${value.toFixed(1)}%`;
  return fmt.format(value);
}

よくある質問

なぜ Recharts Tooltip フォーマッタ コールバックはパラメータを明示的に入力してはならないのですか?

Recharts の formatter プロパティの TypeScript 型は、パラメーターに注釈を付けるときに型の競合を引き起こす方法でオーバーロードされています。正しいパターンは、value の型に注釈を付けずに、Number(value) を使用して値を安全に変換することです。 (value: number) として注釈を付けると、Recharts の内部署名の共用体型が原因で TypeScript がエラーになる可能性があります。 TypeScript に型を推測させ、Number(value) で変換させます。

Recharts チャートをダーク モードで動作させるにはどうすればよいですか?

すべての色に対して、ハードコードされた 16 進値の代わりに CSS カスタム プロパティ (変数) を使用します。グリッド線の場合は hsl(var(--border))、軸のテキストの場合は hsl(var(--muted-foreground))、ツールチップの背景の場合は hsl(var(--background)) を使用してそれらを参照します。これらの変数は Tailwind テーマで定義され、<html>dark クラスを介してライト モードとダーク モードの間で値を自動的に切り替えます。 #374151 をハードコードしないでください。テーマでは更新されません。

チャートの空または読み込み状態を処理するにはどうすればよいですか?

ロードの場合: チャートをスケルトン (animate-pulse bg-muted rounded-lg h-80) に置き換えます。空のデータの場合: ResponsiveContainer 内の中央に空の状態メッセージをレンダリングします。部分データの場合: Recharts はスパース データを適切に処理します。手元にあるものを渡すだけで点が結ばれます (または connectNulls={false} で null 値のギャップが表示されます)。

Recharts、Chart.js、または D3 を使用する必要がありますか?

React ネイティブ ダッシュボード用の Recharts — 宣言型で構成可能で、React のレンダリング モデルと自然に統合されます。 Canvas ベースのレンダラー (10,000 以上のデータ ポイントのパフォーマンスが向上) が必要な、より単純なユースケースの場合は Chart.js。ライブラリでカバーされていないカスタムの非標準チャート タイプが必要な場合は、D3 を使用します。大幅に多くのコードが必要になります。 1 つのアプリケーションに複数のグラフ作成ライブラリを混在させないでください。

グラフをレポート用の画像としてエクスポートするにはどうすればよいですか?

html2canvas ライブラリを使用して、チャート コンテナーを画像 const canvas = await html2canvas(chartRef.current); const url = canvas.toDataURL('image/png'); としてキャプチャします。あるいは、Recharts の組み込み SVG 出力を使用します。Recharts は SVG をレンダリングするため、ベクター エクスポート用に SVG 要素を直接シリアル化できます。 PDF レポートの場合は、Puppeteer またはヘッドレス ブラウザを使用してサーバー側でグラフをレンダリングします。


次のステップ

データ視覚化は、生の指標をビジネス インテリジェンスに変換します。このガイドのパターン (応答性の高いコンテナー、カスタム ツールチップ、リアルタイム ストリーミング、テーマに応じた色) は、どのデバイスでも、どのような配色でも明確に通信できるダッシュボード UI の基盤を提供します。

ECOSIRE は、NestJS API と Drizzle を利用した PostgreSQL からのライブ データを使用して、財務、運用、パフォーマンスのメトリクスのための 8 つのダッシュボード モジュールにわたる Recharts を使用した分析ダッシュボードを構築します。ビジネス インテリジェンスと Power BI の実装については、Power BI サービスを調べる または すべてのサービスを表示 してください。

E

執筆者

ECOSIRE Research and Development Team

ECOSIREでエンタープライズグレードのデジタル製品を開発。Odoo統合、eコマース自動化、AI搭載ビジネスソリューションに関するインサイトを共有しています。

WhatsAppでチャット