管理ダッシュボードをReactで作る:チャート選びからURL同期まで
管理ダッシュボードをReactで作るときに迷う5点を実装で解決。チャート選び、データ取得とキャッシュ、フィルタのURL同期、グリッド、大量データ対策を動くコードで。
「管理画面のダッシュボードを作って」
Claude Codeにそう投げて出てきたのは、グラフが3つ並んだきれいな画面でした。デモでは完璧。でも本番データを流し込んだ瞬間に崩れたんです。期間フィルタを変えてもグラフが追従しない。ブラウザの戻るボタンを押すと表示が初期化される。1万行のテーブルでスクロールがカクつく。誰かにURLを送っても、その人の画面では別の期間が出る。
見た目だけ作ると、こうなります。
管理ダッシュボードの本体は、棒グラフでも円グラフでもありません。「期間とフィルタを切り替えるたびに、画面・URL・データ取得がぴったり揃って動く」仕組みのほうです。グラフは最後に乗せる飾りで、難所はその下の配管にあります。今日はそこを、コピペで動くコード中心で組み立てます。
この記事の要点
- 管理ダッシュボードで詰まるのは大きく5点。チャート選び・データ取得とキャッシュ・フィルタのURL同期・グリッドレイアウト・大量データの描画。
- チャートライブラリはRecharts(Reactに馴染む宣言的API)を基準にし、凝った図だけD3へ逃がす。選定の詳細はチャートライブラリ比較記事へ。
- データ取得はfetchを直書きせず、TanStack Queryでキャッシュ・再取得・ローディングを一括管理する。
- 期間やフィルタは
useStateではなくURLクエリに持たせる。これだけで共有・ブックマーク・戻る進むが全部直る。 - 行数が増えたら表は仮想スクロール、グラフは間引き集計で守る。Claude Codeには「画面」より先に「データの契約」を作らせる。
管理ダッシュボードで本当に詰まる5つの場所
最初に全体像を出します。グラフの種類で悩む時間は実はわずかで、効くのはこの5つです。
| 詰まる場所 | やりがちな失敗 | この記事の方針 |
|---|---|---|
| チャート選び | とりあえずD3で全部描いて沼る | Rechartsを基準、凝った図だけ別ライブラリ |
| データ取得 | useEffectでfetch直書き | TanStack Queryでキャッシュと再取得を任せる |
| フィルタ | useStateに期間を持つ | URLクエリに持たせて共有可能にする |
| レイアウト | 固定幅で並べて崩れる | CSS Gridでレスポンシブに自動折返し |
| 大量データ | 1万行をそのまま描画 | 表は仮想スクロール、グラフは間引き |
このうち2〜4は「配管」です。配管が通っていれば、グラフ部品の差し替えは数行で済みます。逆にグラフから作り始めると、フィルタを足すたびに全部書き直すことになります。だから順番が大事なんですね。
チャートライブラリは「宣言的に書けるか」で選ぶ
管理ダッシュボードの9割は、折れ線・棒・面・円で足ります。ここで凝った可視化ライブラリを持ち出すと、学習コストが跳ね上がるだけです。
僕の基準はシンプルです。
- Recharts: ReactのコンポーネントとしてJSXで書ける。
<LineChart>の中に<Line>を置く感覚。管理画面の標準KPIならこれで十分。 - Chart.js: canvas描画で軽い。React連携は
react-chartjs-2が必要で、設定はオブジェクト渡し。点数が多い時間系列に強い。 - D3.js: なんでも描けるが、自分でSVGを組む低レイヤー。サンキー図やネットワーク図など、上2つで描けない図だけに使う。
最初はRecharts一本でいい、というのが結論です。ライブラリ選定の比較表と判断フローはRecharts・Chart.js・D3の使い分けに詳しく書いたので、迷ったらそちらを。グラフの「見せ方」自体で悩むならデータ可視化の考え方が起点になります。
Rechartsを入れるのはこれだけです。
npm install recharts @tanstack/react-query
公式の使い方はRecharts公式ガイドを見ると早いです。ResponsiveContainerで囲むと親要素の幅に追従するので、グリッドの中でも崩れません。
データ取得は素のfetchをやめてキャッシュに任せる
ダッシュボードは同じデータを何度も読みます。タブを切り替えて戻る、期間を変えてまた戻す、別の人が同じ画面を開く。ここでuseEffectの中にfetchを直書きすると、毎回ネットワークが走り、ローディングのちらつきとレースコンディションが必ず出ます。
useStateとuseEffectで取得を書くと、こういう状態を全部自分で管理することになります。
- 読み込み中フラグ
- エラー
- 取得済みデータのキャッシュ
- 古くなったときの再取得
- 連打したときの古いレスポンス破棄
これを毎画面で手書きするのは事故のもとです。TanStack Queryに任せると、queryKey(このデータの住所)とqueryFn(取り方)を渡すだけで、上の5つを全部見てくれます。SWRでもほぼ同じ発想で組めますが、無効化や楽観的更新まで踏み込むならTanStack Queryが扱いやすい。キャッシュ設計の踏み込んだ話はTanStack Queryのキャッシュ設計にまとめました。
ポイントは、queryKeyにフィルタ条件を含めることです。["dashboard", from, to, plan]のようにすると、期間やプランを変えた瞬間に別のキャッシュとして扱われ、自動で再取得が走ります。これが後で「URL同期」とつながります。
フィルタはuseStateではなくURLに持たせる
ここが管理ダッシュボードの一番のキモです。
期間やプランの選択をuseStateで持つと、見た目は動きます。でも、その状態は画面の中に閉じ込められます。URLを送っても相手は別の条件を見るし、ブラウザの戻るは効かないし、リロードで消えます。
代わりにURLのクエリ文字列を唯一の状態にします。?from=2026-05-01&to=2026-05-31&plan=proをそのまま「いま何を見ているか」にするわけです。すると勝手にこうなります。
- URLをSlackやメールでそのままシェアできる。相手も同じ画面を見る。
- ブラウザの戻る・進むが、期間の戻る・進むになる。
- リロードしても条件が消えない。
- その条件を
queryKeyに渡せば、データ取得とも自動で同期する。
Next.jsのApp RouterならuseSearchParamsでクエリを読み、router.replaceで書き換えます。React RouterでもuseSearchParamsという同名のフックがあり、考え方は同じです。状態をどこに置くか全体の判断軸はReact状態管理の整理も参考になります。
コピペで動く:URL同期つきダッシュボード
ここまでの「データ取得(TanStack Query)」「URL同期」「Rechartsグラフ」を1つにまとめた、そのまま貼れるNext.js(App Router)のclient componentです。@tanstack/react-queryのProviderだけ親側に置けば動きます。期間ボタンを押すとURLが変わり、URLが変わるとデータが取り直され、グラフが更新されます。
"use client";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import {
CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis,
} from "recharts";
// 1日あたりの売上を表す行。APIが返すJSONの形をここで固定する
type DailyPoint = { date: string; revenue: number };
// 期間プリセット(日数)。URLには from / to の日付文字列を入れる
const PRESETS = [
{ label: "7日", days: 7 },
{ label: "30日", days: 30 },
{ label: "90日", days: 90 },
];
function isoDaysAgo(days: number) {
const d = new Date();
d.setDate(d.getDate() - days);
return d.toISOString().slice(0, 10); // YYYY-MM-DD
}
// 通貨表示はIntlに任せる。手書きの "¥" 連結はバグの温床
const yen = new Intl.NumberFormat("ja-JP", {
style: "currency", currency: "JPY", maximumFractionDigits: 0,
});
async function fetchSeries(from: string, to: string): Promise<DailyPoint[]> {
const res = await fetch(`/api/dashboard/series?from=${from}&to=${to}`);
if (!res.ok) throw new Error(`取得に失敗しました (${res.status})`);
return res.json();
}
export default function DashboardPage() {
const params = useSearchParams();
const router = useRouter();
const pathname = usePathname();
// 状態の置き場所はURLだけ。useStateで期間を持たない
const to = params.get("to") ?? isoDaysAgo(0);
const from = params.get("from") ?? isoDaysAgo(30);
// URLを書き換える。これがフィルタ変更の唯一の入口
function applyPreset(days: number) {
const next = new URLSearchParams(params);
next.set("from", isoDaysAgo(days));
next.set("to", isoDaysAgo(0));
router.replace(`${pathname}?${next.toString()}`); // 履歴を汚さず差し替え
}
// queryKeyに from/to を入れる → 期間が変わると自動で取り直す
const { data, isPending, isError, error } = useQuery({
queryKey: ["dashboard", "series", from, to],
queryFn: () => fetchSeries(from, to),
staleTime: 60_000, // 1分間はキャッシュを新鮮扱いして無駄打ちを防ぐ
});
return (
<main className="space-y-4 p-6">
<header className="flex flex-wrap items-center gap-3">
<h1 className="text-xl font-bold">売上ダッシュボード</h1>
<nav className="flex gap-2" aria-label="期間の切り替え">
{PRESETS.map((p) => {
const active = from === isoDaysAgo(p.days) && to === isoDaysAgo(0);
return (
<button
key={p.days}
onClick={() => applyPreset(p.days)}
aria-pressed={active}
className={`rounded border px-3 py-1 text-sm ${
active ? "bg-slate-900 text-white" : "bg-white"
}`}
>
{p.label}
</button>
);
})}
</nav>
<span className="text-sm text-slate-500">
{from} 〜 {to}
</span>
</header>
{isPending && <p aria-busy="true">読み込み中…</p>}
{isError && (
<p role="alert" className="rounded border border-red-300 bg-red-50 p-3 text-sm">
{error instanceof Error ? error.message : "不明なエラー"}
</p>
)}
{data && data.length === 0 && <p>この期間に表示できるデータがありません。</p>}
{data && data.length > 0 && (
<section className="rounded border bg-white p-4">
<ResponsiveContainer width="100%" height={320}>
<LineChart data={data} aria-label="日次売上の折れ線グラフ">
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis tickFormatter={(v) => yen.format(Number(v))} width={90} />
<Tooltip formatter={(v) => yen.format(Number(v))} />
<Line type="monotone" dataKey="revenue" stroke="#2563eb" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</section>
)}
</main>
);
}
このコードの肝は3行だけ覚えれば十分です。const from = params.get("from")で状態をURLから読む。router.replaceでURLを書き換える。queryKey: ["dashboard", "series", from, to]でURLとデータ取得を縛る。この三角形が回ると、共有も戻る進むもキャッシュも全部勝手に効きます。
レイアウトはCSS Gridで「数を変えても崩れない」形に
KPIカードを横並びにして、画面幅が狭いと折り返したい——これは固定幅のflexで頑張ると必ず崩れます。CSS Gridのauto-fillとminmaxを使うと、カードの最小幅だけ決めれば残りは自動です。
function KpiGrid({ children }: { children: React.ReactNode }) {
return (
// 最小240px、入るだけ詰めて、入らなければ折り返す
<section
style={{
display: "grid",
gap: "1rem",
gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
}}
>
{children}
</section>
);
}
カードが3枚でも7枚でも、画面が広ければ1行に、狭ければ自動で段組みが変わります。Tailwindならgrid gap-4 [grid-template-columns:repeat(auto-fill,minmax(240px,1fr))]でも同じです。メディアクエリを書き散らすより、こちらのほうが壊れません。レスポンシブの判断軸はメディアクエリの実装も合わせてどうぞ。
大量データは「描く前に減らす」
行数が増えると、ダッシュボードは2か所で重くなります。表とグラフです。
表: 1万行をmapでそのまま<tr>にすると、DOMノードが1万個できてスクロールが固まります。画面に見えている十数行だけ描く仮想スクロールにします。TanStack Virtualなどを使うと、行数が増えてもDOM数は一定です。実装手順はReact仮想スクロールの実装にまとめました。
グラフ: 折れ線に1万点を渡すと、SVGのパスが巨大になって描画が遅くなります。人間の目は1000点も見分けられないので、サーバー側で日次・週次に集計してから渡すのが基本です。どうしてもクライアントで間引くなら、こうします。
// N点を超えたら間引いて最大maxPoints点にする(端は必ず残す)
function downsample<T>(rows: T[], maxPoints = 500): T[] {
if (rows.length <= maxPoints) return rows;
const step = Math.ceil(rows.length / maxPoints);
const out = rows.filter((_, i) => i % step === 0);
if (out[out.length - 1] !== rows[rows.length - 1]) out.push(rows[rows.length - 1]);
return out;
}
ただ、間引きは最後の手段です。まずはAPI側で集計する。クライアントに生データを1万件送っている時点で、たいてい設計を見直すサインです。計測してから直す進め方はパフォーマンス最適化の実践に書いています。
Claude Codeには「画面」より先に「データの契約」を作らせる
ここまでの配管をClaude Codeに任せるとき、いきなり「ダッシュボード作って」と言うと見た目だけ立派な画面が返ってきます。順番を指定するのがコツです。
- APIが返すJSONの形(契約)を先に決めさせる。日付・通貨単位・タイムゾーン・件数まで含める。
- その契約をTypeScriptの型に落とさせる。型があると、後で単位やフィールドを落としたときに気づける。
- URL同期のフィルタを実装させる。
useStateで期間を持っていたら却下する。 - TanStack Queryで取得させる。
queryKeyにフィルタが入っているか必ず確認する。 - 最後にグラフとグリッドを乗せる。ローディング・エラー・空データの3状態を必ず出させる。
この順番を守るだけで、出力の質がはっきり変わります。レビュー観点や権限の話まで含めて実務に落とすならNext.jsフルスタック実務ガイド、見せるKPIをロールで出し分けるならRBACの実装が続きになります。
チーム標準のテンプレートやレビュー用プロンプトは教材一覧にまとめてあります。
僕がダッシュボードで踏んだ地雷3つ
正直に書きます。最初のダッシュボードは作り直しだらけでした。
ひとつ目は、期間をuseStateで持ったこと。デモは完璧でした。でも顧客に「この画面のURLください」と言われた瞬間に詰みました。送ったURLでは初期表示しか出ない。URLに状態を移すまで丸一日溶かしました。
ふたつ目は、fetchをuseEffectに直書きしたこと。タブを高速で切り替えると、古いレスポンスが後から届いて画面が一瞬巻き戻る。レースコンディションです。TanStack Queryに移したら、その日のうちに消えました。
みっつ目は、生データを全部クライアントに送ったこと。検証用の小さいデータでは速かったんです。本番の1万行で初めてカクついた。サーバー側集計に変えてからは、データが10倍になっても体感は変わっていません。
よくある質問
Q. RechartsとChart.js、結局どっちがいい? 管理画面の標準KPI(折れ線・棒・面・円)ならRechartsが書きやすいです。JSXでそのまま組めるので、Reactに慣れているほど速い。時系列で点数が極端に多い、あるいはcanvasの軽さが欲しいならChart.jsが候補です。比較の詳細はチャートライブラリ比較に。
Q. SWRとTanStack Query、どちらを選ぶ? 最初の取得とキャッシュだけならどちらでも大差ありません。無効化・楽観的更新・依存クエリまで踏み込むならTanStack Queryのほうが道具が揃っています。この記事のコードもそちら基準で書きました。
Q. 状態をURLに入れると、見せたくない条件まで露出しない? 公開してまずい値(他テナントのIDなど)はURLに入れず、サーバー側の認可で弾きます。URLに置くのは期間・プラン・並び順のような「共有して困らない表示条件」だけにします。
Q. グラフが多いと初期表示が遅い。どうする? 画面外のグラフは遅延読み込みにし、データはAPI側で集計してから渡します。それでも重ければ、表は仮想スクロール、折れ線は間引き。順番はパフォーマンス最適化の流儀で、まず計測してからです。
Q. Next.jsじゃなくてViteのReactでも同じ作り方でいい?
配管の考え方は同じです。URL同期はReact RouterのuseSearchParams、データ取得はTanStack Queryで、コードはほぼそのまま使えます。違うのはルーティングAPIの名前くらいです。
まとめ
管理ダッシュボードの出来は、グラフの種類ではなく配管で決まります。チャートはRechartsを基準に置き、データ取得はTanStack Queryに任せ、フィルタはURLに持たせ、レイアウトはGridで折り返し、大量データは描く前に減らす。この5つが通っていれば、グラフの差し替えは数行です。
最初に作るべきは、見た目ではなくAPIの契約。Claude Codeにもその順番で頼む。上のコードをそのまま貼って、まずは「期間ボタンを押すとURLが変わり、データが取り直される」三角形を一度動かしてみてください。そこが回り出すと、ダッシュボードは一気に組み上がります。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。