4
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【ダッシュボード】実務を見える化!React/Next.jsにグラフ Recharts を導入

4
Last updated at Posted at 2026-03-07

🎯 この記事の対象者

  • 業務開発でグラフを提供したい方
  • React/Next.js の基礎を終え、「実務レベルの実装」へ一歩踏み出したい方
  • 「動く」だけでなく、「ユーザーに優しく、データに堅牢な」設計を学びたい方

何か誤りなどあれば、お気軽にコメントしてください。
私自身も探求中の未熟者なのでご容赦ください🙇


✅ 基本を振り返りたい方へ
本記事は単体で完結していますが、グラフの元データとなる「受注データ」の登録・確認について詳しく知りたい方は、以前公開した受注管理システム編もあわせてご覧ください。


🚀 この記事で学べること

✅ 対象データの絞り込みと共有

  • 条件指定コンポーネントの入力結果の共有方法
  • URLベースの状態管理(検索条件の保持)の適用

✅ 本格的な「グラフ」の活用

  • 経営層へ視覚的に訴える実務データを見える化
  • コンポーネント志向でUIデザインの自由度が高いRechartsを利用したグラフ実装の実例
  • グラフをクリックして、受注一覧へドリルダウン

✅ 信頼性の高いバックエンド処理

  • Next.js 15 (App Router) × Drizzle ORM による、サーバーサイドでの効率的なデータ集計と型安全な Repository 実装

🔎 アプリのイメージ

◆ 月間売上金額
image.png

◆ 週間受注件数
image.png

◆ 受注明細へドリルダウン
(クリックしたグラフバーの期間や得意先、商品がそのまま絞り込まれます)
image.png

「分析期間を切り替えたとき、URL パラメータがどう変化するか」 に注目しながら触ってみてください。URL が状態の正解(SSOT)であることの利便性を体感できるはずです!

デモサイトはこちら


1.全体の構成

✅ ディレクトリ構造
(ダッシュボード部分だけを抜粋)

src/
├── app/(protected)/dashboard/
│   ├── _components/
│   │   ├── 検索条件.tsx             # 【Client】分析期間の指定
│   │   ├── 商品Ranking.tsx        # 【Client】商品ランキング
│   │   ├── 商品RankingServer.tsx   # 【Server】商品ランキング データフェッチ
│   │   ├── 得意先Ranking.tsx       # 【Client】得意先ランキング
│   │   ├── 得意先RankingServer.tsx  # 【Server】得意先ランキング データフェッチ
│   │   ├── 売上推移Chart.tsx        # 【Client】売上推移チャート
│   │   └── 売上推移ChartServer.tsx   # 【Server】売上推移チャート データフェッチ
│   └── page.tsx                    # 【Server】ダッシュボードのメインページ
│
├── db/
│   ├── model/
│   │   └── 受注Model.ts           # 【Shared】Zodによる入力検証・型変換ロジック
│   ├── repository/
│   │   └── 受注分析Repository.ts   # 【Server Only】Drizzleを使用した純粋なDB操作
│   └── schema.ts                  # 【Shared】DBテーブル定義(Drizzle Schema)
│
└── lib/
    └── analysis-utils.ts          # グラフ表示用の自作関数群

💡 この構成のポイント

(1) URLですべてを管理

  • 「今どの期間を見ているか」はすべてURL(パラメータ)で決まります
  • URLをコピーして送るだけで、誰でも同じグラフが見られる 「共有しやすさ」 を重視しています

(2) 重い処理はサーバーにお任せ

  • 複雑な集計はデータベース(PostgreSQL)側で高速に行います
  • Next.jsのサーバーコンポーネントはそれを受け取り加工するだけです

(3) ビジネス現場の「使い勝手」を追求

  • 売上が0の日のグラフも飛ばさず作成する
  • ラベルの土日を色分けする
  • クリックで詳細画面へ飛べる(ドリルダウン)
    など、実務でそのまま使える工夫を詰め込んでいます

🔤 デモアプリのコード


2.分析期間の指定操作

✅ 分析条件のSingle Source of Truth(SSOT)設計

このダッシュボードの最大の特徴は、分析に必要な状態を AnalysisParams という一つの構造体に集約し、それをURLパラメータと完全に同期させている点です。

分析条件を集約する AnalysisParams
lib/analysis-utils.ts にて、分析に必要な項目を定義しています。

src/lib/analysis-utils.ts
import { z } from "zod";

// 基本的な型定義
export type AnalysisPreset = "week" | "month" | "year";
export type AnalysisInterval = "day" | "month";
export type AnalysisDirection = "current" | "prev" | "next";

export type AnalysisDuration = {
  from: string; // yyyy-MM-dd
  to: string; // yyyy-MM-dd
};

// 日付形式 (yyyy-MM-dd) かつ 実在する日付であることをバリデーション
const dateStringSchema = z
  .string()
  .regex(/^\d{4}-\d{2}-\d{2}$/, "日付形式(yyyy-MM-dd)で入力してください")
  .refine((val) => {
    const [y, m, d] = val.split("-").map(Number);
    const date = new Date(y, m - 1, d);
    // JSのDateは「2月31日」を「3月3日」などに自動変換するため、
    // 入力値と変換後の値が一致するかで実在性を判定する
    return (
      date.getFullYear() === y &&
      date.getMonth() === m - 1 &&
      date.getDate() === d
    );
  }, "実在しない日付です");

// プリセットから集計単位を一義的に決定する(関数従属の定義)
const getIntervalByPreset = (preset: AnalysisPreset): AnalysisInterval => {
  return preset === "year" ? "month" : "day";
};

// =============================================
// Zod スキーマ定義
// =============================================
export const analysisParamsSchema = z
  .object({
    preset: z.enum(["week", "month", "year"]).catch("month"),
    from: dateStringSchema,
    to: dateStringSchema,
    direction: z.enum(["current", "prev", "next"]).default("current"),
  })
  .transform((data) => {
    // presetからintervalを導出(関数従属をtransform内に封じ込める)
    const interval: AnalysisInterval = data.preset === "year" ? "month" : "day";

    return {
      preset: data.preset,
      direction: data.direction,
      interval,
      duration: {
        from: data.from,
        to: data.to,
      },
    };
  });

// 最終的な AnalysisParams 型
export type AnalysisParams = z.infer<typeof analysisParamsSchema>;

属性間の「関数従属(一方が決まれば他方も決まる関係)」を利用した整合性の確保

  • interval(集計単位)はユーザーが直接指定するのではなく、preset(表示モード)に応じて一義的に決定されます
  • これを関数従属(Preset → Interval)させることで、UIの複雑さを排除し、データの整合性を担保しています
  • 週間・月間表示:interval: "day"(日次集計)
  • 年間表示:interval: "month"(月次集計)

✅ AnalysisDuration が string 型である理由

分析期間(from, to)は、JavaScript の Date オブジェクトではなく、あえて yyyy-MM-dd 形式の string 型 で管理します。

(1) URL との親和性(シリアライズの不要化)

  • ダッシュボードの状態(期間)は常に URL クエリパラメータと同期しています
  • クエリパラメータは常に「文字列」であるため、型を string に統一しておくことで、URL からの読み取り・URL への反映時に、面倒なパース処理や型変換のオーバーヘッドを削減できます

(2) サーバー・クライアント間の不整合防止

  • Date オブジェクトは、実行環境(ブラウザかサーバーか)のタイムゾーン設定によって解釈がズレるリスクがあります
  • yyyy-MM-dd という「ただの文字列」として扱うことで、サーバーサイド(RSC)での DB クエリ発行時も、クライアントサイドでの表示時も、常に同じ日付を指し示すことが保証されます

(3) URL セーフな書式(yyyy-MM-dd)

  • スラッシュ区切り(yyyy/MM/dd)ではなく、ハイフン区切り(yyyy-MM-dd)を採用しています
  • これは、URL のパスセパレータ(/)との混同を避けつつ、ISO 8601 形式に準拠した Web 標準の扱いやすい書式であるためです

✅ クライアント側での条件変更とURL同期

検索条件コンポーネント(検索条件.tsx)は、ユーザーの操作(タブ切り替えや期間移動)をトリガーに、次の状態を計算してURLへ反映する役割を担います。

UI操作:
ユーザーが「次へ」ボタンや「月間」タブをクリック

パラメータ算出:
calculateAnalysisParams 関数で、現在の状態を基に新しい from, to を算出

URL反映:
router.push を実行。intervalpreset から導出可能

src/app/(protected)/dashboard/_components/検索条件.tsx
// URLパラメータの更新:interval は preset から導出されるため、URLからは除外
const navigate = (next: AnalysisParams) => {
  const urlParams = new URLSearchParams(searchParams.toString());
  urlParams.set("preset", next.preset);
  urlParams.set("from", next.duration.from);
  urlParams.set("to", next.duration.to);

  // interval の set を削除
  urlParams.delete("interval");

  startTransition(() => {
    router.push(`?${urlParams.toString()}`);
  });
};

3.データフェッチ・コロケーションとメモリゼーション

ダッシュボードには「売上推移」「得意先ランキング」「商品ランキング」など、性質の異なる複数のデータが必要です。これらを効率的に、かつユーザーを待たせずに表示するための設計を解説します。

✅ サーバーコンポーネントによる個別フェッチ

以前の Next.js では page.tsx で全てのデータを Promise.all で一括取得するのが一般的でしたが、現在は 「各コンポーネントが自分に必要なデータを自分で取得する(コロケーション)」設計が推奨されます。

Next.js 15 における自動並列実行
Promise.all を明示的に書かなくても、Next.js はレンダリングツリー内の各コンポーネントが要求する await を自動的に並列で処理開始します。


✅ ストリーミングと Suspense の配置

各コンポーネントを Suspense でラップすることで、準備ができたグラフから順次表示されます。

src/app/(protected)/dashboard/page.tsx
<main>
  {/* 売上推移チャートエリア */}
  <Suspense fallback={<LoadingCard label="売上推移を計算中..." />}>
    <SalesTrendServer params={params} />
  </Suspense>

  {/* ランキングエリア */}
  <Suspense fallback={<LoadingCard label="得意先別集計中..." />}>
    <CustomerRankingServer params={params} />
  </Suspense>
  <Suspense fallback={<LoadingCard label="商品別集計中..." />}>
    <ProductRankingServer params={params} />
  </Suspense>
</main>

✅ なぜ一括取得(Promise.all)ではなく分割(Suspense)なのか?

  • ストリーミング表示(UXの向上)
    Promise.all の場合、3つのクエリのうち1つでも重いものがあると、全てのグラフが表示されるまで画面が真っ白になります。分割することで、軽いランキングデータが先に表示され、重いチャートは後からパッと出てくる「段階的な表示」が可能になります。

  • 関心の分離と再利用性
    「得意先ランキング」というコンポーネントの中にデータ取得ロジックが閉じ込められているため、このコンポーネントを別のページ(例:得意先詳細画面)へ移動させても、そのままデータ付きで動作します。

  • Request Memoization による安全な重複呼び出し
    「各コンポーネントで個別に呼ぶと、同じデータを二重に取得してしまわないか?」という不安は、前述の React.cache(Request Memoization)が解決します。

「キャッシュを活用することで、パフォーマンスを意識せずに必要な場所でデータを呼ぶ」
この設計思想により、複雑なダッシュボードでも「表示の速さ」と「コードの美しさ(疎結合)」を高い次元で両立できます。

💡 実装のポイント:サーバーコンポーネントの「殻」を作る
UIを担当する SalesTrendChart(Client)を直接 page.tsx から呼ぶのではなく、データ取得を担当する SalesTrendServer(Server)を一枚挟むのがポイントです。


✅ Drizzle ORM を利用したメモ化

  • 標準の fetch は自動的にメモ化されます
  • Drizzle(DBクライアント)を介した関数は自動でメモ化されません
  • 明示的に React.cache でラップする必要があります。
src/db/repository/受注分析Repository.ts
import "server-only";
import { cache } from "react";

// キャッシュを通さない内部処理は公開しない
const _impl = {
  // 実際のDBアクセス
}

// 繰り返し呼び出される分析用のデータフェッチをキャッシュ化する 
export const 受注分析Repository = {
  // 売上高推移の取得 (同一リクエスト内メモ化対象)
  GetSalesTrend: cache(_impl.getSalesTrend),

  // 得意先別売上ランキング (同一リクエスト内メモ化対象)
  GetTopCustomers: cache(_impl.getTopCustomers),

  // 商品別売上ランキング (同一リクエスト内メモ化対象)
  GetTopProducts: cache(_impl.getTopProducts),
};

4.Drizzle ORM による動的な集計クエリの実装

ダッシュボードの核心である「期間別集計」を、PostgreSQLの強力な関数を活用しつつ、型安全に実装する手法を解説します。

(1) 日時集計か?月次集計か?(date_trunc を利用した動的なグループ化)

  • PostgreSQLの date_trunc 関数は date型から必要な部分だけを取り出せます
    • date_trunc('month', ...) : 任意のどの日付も「その月の1日 00:00:00」に丸める
    • date_trunc('day', ...) : 「その日の 00:00:00」に丸める(時分秒の切り捨て)

(2) SQLの断片を変数化してDRYに保つ

  • SQLでは、SELECT 句で計算した式を使ってグループ化したい場合、GROUP BY 句にも全く同じ式を記述しなければならないという制約があります
  • date_trunc を SELECT句と GROUP BY句の両方に書かない方法があります
  • Drizzle ORMでは、sql タグの結果を変数(period)に格納することで、この重複をスマートに解決できます

(3) 型指定:sql と .mapWith() の使い分け

  • sql(看板の役割):
    • TypeScriptコンパイラに対して「この結果は数値として扱うよ」と宣言します
  • .mapWith(Number)(工場の役割):
    • PostgreSQLの sum や count は、巨大な数値の桁落ちを防ぐために「文字列」で返ることがあります
    • これを実行時に JavaScript の Number 型へ確実に変換(キャスト)します
src/db/repository/受注分析Repository.ts
async GetSalesTrend(duration: AnalysisDuration, interval: AnalysisInterval) {
  // period(SQLの断片)を Drizzle ORM の機能で定義する
  const period =
    interval === "month"
      ? sql<string>`date_trunc('month', ${受注.受注日})` // 日付をtruncして文字型で受ける
      : sql<string>`date_trunc('day', ${受注.受注日})`;

  return await db
    .select({
      period: period,  // 定義したperiodを利用する
      totalAmount: sql<number>`sum(${受注.合計金額})`.mapWith(Number),
      count: sql<number>`count(${受注.受注ID})`.mapWith(Number),
    })
    .from(受注)
    .where(
      and(gte(受注.受注日, duration.from), lte(受注.受注日, duration.to)),
    )
    // 変数 period を再利用することで、SELECT 句と GROUP BY 句の式を完全に一致させる
    .groupBy(period)  // 定義したperiodを利用する
    .orderBy(period);
},

💡 発行されるSQLのイメージ

SELECT 
  date_trunc('month', "受注"."受注日") AS "period", -- SELECT句
  sum("受注"."合計金額") AS "totalAmount",
  count("受注"."受注ID") AS "count"
FROM "受注"
WHERE "受注"."受注日" >= '2024-01-01' AND "受注"."受注日" <= '2024-12-31'
GROUP BY date_trunc('month', "受注"."受注日")        -- GROUP BYに同じ式が必要
ORDER BY date_trunc('month', "受注"."受注日");       -- ORDER BYも同様

5.Rechartsによるデータの可視化とUIの工夫

バックエンドで集計したデータを、ユーザーが直感的に理解できる「生きたグラフ」に落とし込むためのUI実装について解説します。

✅ 空データの作成:「データの欠損」を埋める

DBの集計結果には「受注が1件もなかった日」のデータは含まれません。そのままグラフに渡すと、日付が飛んでしまい、時系列グラフとして不自然な形になります。

空データの生成:
generateEmptyTrendData で期間内の全日付を網羅した「売上0」の配列を生成します

マージ処理:
生成した空の配列に、DB から取得した実データを JavaScript の Map 等を使ってマージします

src/lib/analysis-utils.ts
// 指定された期間内に「受注0」で埋められた初期データ配列を生成
export const generateEmptyTrendData = (
  duration: AnalysisDuration,
  interval: AnalysisInterval,
) => {
  // 期間内をループし、全期間の空データを作成
};

✅ 受注ありと空データのマージ

2つのデータをマージ(合成)し、グラフ用のデータを作成します。

src/app/(protected)/dashboard/_components/売上推移Chart.tsx
// useMemoで依存変数が変わるまで結果を保持
const chartData = useMemo(() => {
  // 1. 全日程分の「空の行」を作成
  const emptyData = generateEmptyTrendData(duration, interval);

  // 2. DBから届いたデータを「日付」で検索できる辞書に変換
  const dataMap = new Map(
    data.map((item) => [dateFormatJPLocal(new Date(item.period)), item]),
  );

  // 3. 空の行をベースに、実データがあれば上書きする
  return emptyData.map((emptyRow) => {
    // この日付のデータ行(dataRow)はあるか?
    const dataRow = dataMap.get(emptyRow.period);

    // 実データ(dataRow)があればマージ、なければ空の行(emptyRow)をそのまま使う
    const merged = dataRow ? { ...emptyRow, ...dataRow } : emptyRow;

    return merged;
  });
}, [data, duration, interval]);

✅ ひと目で「曜日」がわかるX軸のカスタマイズ
ビジネス分析では「土日の動き」が重要になることが多いため、Rechartsの XAxis をカスタマイズして、土曜日を青、日曜日を赤で表示する実装を行いました。

image.png

src/app/(protected)/dashboard/_components/売上推移Chart.tsx
const CustomXAxisTick = ({ x, y, payload }: CustomTickProps) => {
  const label = payload.value;
  let color = "#64748b";
  let fontWeight = "normal";

  // 文字列に「日」または「土」が含まれるかで判定
  if (label.includes("")) {
    color = "#ef4444";
    fontWeight = "bold";
  } else if (label.includes("")) {
    color = "#3b82f6";
    fontWeight = "bold";
  }

  return (
    <text
      x={x}
      y={y + 16}
      fontSize={12}
      textAnchor="middle"
      className="select-none"
      style={{ fill: color, fontWeight }}
    >
      {label}
    </text>
  );
};

✅ 再取得なし!1つのグラフで2つの指標を切り替える
「売上金額」と「受注件数」を1つのグラフで効率よく確認できるよう、ヘッダー部分でアクティブな指標を切り替える設計にしました。

  • 状態管理: useState<"totalAmount" | "count"> で表示中の指標を管理
  • 動的な軸フォーマット: 指標に応じて YAxis の単位を「円・万円・億円」か「件」に切り替え
src/app/(protected)/dashboard/_components/売上推移Chart.tsx
const formatYAxis = (value: number) => {
  if (activeChart === "count") return value.toLocaleString();
  if (value >= 100000000) return `${(value / 100000000).toFixed(1)}億円`;
  if (value >= 10000) return `${(value / 10000).toFixed(0)}万円`;
  return `${value}円`;
};

✅ グラフから詳細へ:ドリルダウン機能
グラフの棒をクリックすることで、その日の「受注一覧」画面へ遷移させます。これにより「なぜこの日の数値が高いのか?」という疑問に即座に応えられます。

src/app/(protected)/dashboard/_components/売上推移Chart.tsx
<Bar
  dataKey={activeChart}
  onClick={(data) => {
    // クリックした日付をクエリパラメータに載せて一覧画面へ
    router.push(`/order?startDate=${data.period}&endDate=${data.period}`);
  }}
/>

ランキング(得意先・商品別)でも同様に、クリックした項目名を検索条件として引き継ぐドリルダウンを実装しています。


✅ ビジネス向けの数値フォーマット
大きな金額をそのまま表示すると桁が多すぎて直感的に理解できません。自作の formatCurrency ヘルパーにより、動的に単位を切り替えています。

  • 12,345,678 → 1,234万円
  • 1,200,000,000 → 12億円

6.現場で差がつく Recharts の詳細プロパティ設定

第4章で紹介した「土日の色分け」や「動的な単位切り替え」などの機能を、具体的にRechartsのどのプロパティを使って実現しているのかを解説します。
Rechartsはデフォルトでも動きますが、業務システムとして耐えうる「使い勝手」を実現するには、各プロパティの微調整が欠かせません。今回実装したコードの中から、特に重要な設定をピックアップします。

✅ XAxis:ラベルの重なりと視認性

日次データ(30日分)を表示する場合、すべてのラベルを表示すると文字が重なってしまいます。

src/app/(protected)/dashboard/_components/売上推移Chart.tsx
<XAxis
  dataKey="displayPeriod"
  tickLine={false}
  axisLine={false}
  interval={preset === "month" ? "preserveStartEnd" : 0}
  tick={(props) => <CustomXAxisTick {...props} />}
/>

interval="preserveStartEnd":
表示領域が狭い場合に、最初と最後のラベルを残して中間を自動で間引きます。これにより、期間の全体像を損なわずに可読性を維持できます。

tick (Custom Component):
Rechartsの標準機能では難しい「土日だけ色を変える」といったロジックを、SVGの 要素を直接制御することで実現しています。


✅ YAxis:動的な単位フォーマット

金額(数千万〜数億)と件数(数件〜数百件)では、適切なラベルの粒度が全く異なります。

src/app/(protected)/dashboard/_components/売上推移Chart.tsx
const formatYAxis = (value: number) => {
  if (activeChart === "count") return value.toLocaleString();
  if (value >= 100000000) return `${(value / 100000000).toFixed(1)}億円`;
  if (value >= 10000) return `${(value / 10000).toFixed(0)}万円`;
  return `${value}円`;
};

// ...
<YAxis
  tickFormatter={formatYAxis}
  width={100}
/>

tickFormatter:
グラフ上の数値を表示用に変換する関数です。これを使うことで、内部データは純粋な number のまま、表示だけをユーザーフレンドリーな単位に丸めることができます。

width={100}:
単位が「円」から「億円」に切り替わった際に、Y軸の幅が伸縮してグラフ全体が左右にガタつくのを防ぐため、十分な固定幅を確保しています。


✅ Bar:チャートをナビゲーションとして使う
単なる「図」ではなく、詳細データへの入り口として機能させます。

src/app/(protected)/dashboard/_components/売上推移Chart.tsx
<Bar
  dataKey={activeChart}
  radius={[4, 4, 0, 0]}
  onClick={(p) => {
    if (!p?.period) return;
    const d = new Date(p.period.replace(/-/g, "/"));
    const end =
      interval === "month"
        ? new Date(d.getFullYear(), d.getMonth() + 1, 0)
        : d;
    startTransition(() =>
      router.push(
        `/order?startDate=${dateFormatJPLocal(d)}&endDate=${dateFormatJPLocal(end)}`,
      ),
    );
  }}
  className="cursor-pointer hover:opacity-80 transition-opacity"
/>

radius={[4, 4, 0, 0]}:
棒の上部だけを角丸にします。モダンなデザインにするための定番設定です。

onClick:
棒グラフの各要素には、対応するデータオブジェクトが渡されます。これを利用して、クリックした日の詳細ページへ遷移させるなど、ドリルダウン機能を簡単に実装できます。

cursor-pointer:
className を通じて cursor: pointer を当てることで、ユーザーに「ここはクリックできる」というアフォーダンス(示唆)を与えます。


✅ 指標切り替えと連動する Tooltip

ユーザーが現在「金額」と「件数」のどちらを見ているのか、ツールチップでも迷わせない工夫をしています。

src/app/(protected)/dashboard/_components/売上推移Chart.tsx
<ChartTooltip
  cursor={{ fill: "#f1f5f9" }}
  content={
    <ChartTooltipContent
      className="w-[140px] bg-white border-slate-200"
      nameKey={activeChart} // 選択中の指標名を表示
    />
  }
/>

cursor={{ fill: "#f1f5f9" }}:
マウスを合わせた列の背景色を変えることで、どの棒に対するツールチップなのかを視覚的に結びつけます。

nameKey={activeChart}:
activeChart のステートを渡すことで、ツールチップ内の項目名(売上金額 / 受注件数)を動的に切り替えています。


まとめ

可視化において重要なのは、単にグラフを描画するだけでなく、
「ビジネスの文脈(休日、単位、詳細への導線)」 をUIに反映させることだと思います。
Next.jsの高速なページ遷移とRechartsの柔軟性を組み合わせることで、ストレスなくダッシュボードを実現できました。

4
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?