D3 で React の棒グラフに transition を載せる
D3 を React と組み合わせて、データ更新時に bar の高さ・軸ラベル・数値 text が同時に transition で動く棒グラフを組むパターン。selection / scale / generator の 3 概念、useRef + useEffect の協調、enter/update/exit の data join、tween による数値カウント、を動く demo で確認する実装メモ。
検証日: 2026-05-11
使用バージョン:
d3@7.x/ React 19 + TypeScript 6対象: Recharts のような高水準ライブラリは触った、もう一段カスタマイズしたい場面で D3 直接書きが必要になった人
Recharts でできない動き(独自 transition、軸の自由装飾、data 数の動的増減)が必要になった時、選択肢は D3 直接書き。本記事は最小の動く例(棒グラフ + 数値 tween)を React + TypeScript で組むパターンの整理。動く demo 付き。
触って試す
データセットを切り替えると、bar の高さ + x 軸 label + 数値 text が同時に滑らかに更新される。これが Recharts の標準 transition では難しいケース(軸 label まで連動して動かす / 数値を「カウントアップ」風に補間する)。
D3 と Recharts の使い分け
「Recharts で十分か、D3 直接書きが必要か」を最初に判断する。
| 場面 | 選択 |
|---|---|
| 標準的な bar / line / pie / area | Recharts(JSX で完結、demo は /articles/recharts-v3-bar-chart-recipes/) |
| 凡例 / Tooltip の細部カスタマイズ | Recharts + 自前 content(/articles/recharts-v3-advanced-customization/) |
| 数値が少しずつ動く / カウントアップ | D3 の tween(本記事) |
| 軸 / 罫線の独自装飾 | D3 の axis + 直接 SVG 操作 |
| ネットワーク図 / force-directed | D3 の d3-force(別記事候補) |
| 地図(Mapbox 系) | deck.gl / Mapbox GL JS(別系統) |
D3 は 低レベル で書く範囲が広い分、Recharts より コード量と学習量 が増える。「Recharts で詰まる具体ケース」だけ D3 に切り替えるのが現実的。
D3 の中心 3 概念
D3 は学ぶ前に 3 つの用語 を押さえると見通しが良くなる:
| 用語 | 役割 | 例 |
|---|---|---|
| selection(DOM 要素の集合) | 「jQuery の $(...) の SVG 版」 | d3.select("svg").selectAll("rect") |
| scale(値 → ピクセル変換) | データ値 100 を SVG の x=150px に変換する関数 | d3.scaleLinear().domain([0, 1000]).range([0, 400]) |
| generator(SVG 描画指示) | 折れ線・面・円弧を data から d=... 文字列に変換 | d3.line() / d3.area() / d3.arc() |
これら 3 つを組み合わせて、<svg> の中に好きな図形を生成する。
React + D3 の協調パターン
React の 「DOM は React が管理する」 と D3 の 「DOM は D3 が直接いじる」 は思想が真逆。これを 1 箇所の境界で切り替える:
import { useEffect, useRef } from "react";
import * as d3 from "d3";
export function MyChart({ data }: { data: Datum[] }) {
const svgRef = useRef<SVGSVGElement | null>(null);
useEffect(() => {
const svg = d3.select(svgRef.current);
if (svg.empty()) return;
// ここから先は D3 の世界
// svg.append(...).attr(...).data(data).join(...)
}, [data]);
return <svg ref={svgRef} viewBox="0 0 400 200" />;
}
境界の決め方:
- React 側:
<svg>の枠 + ref のみ(React は中身に手を出さない) - D3 側:
useEffectの中でd3.select(ref)で取って書く(D3 が中身を全責任管理) - 依存配列に
[data]:data が変わるたび effect が走り、D3 の data join で再描画
この境界を守ると、React の re-render と D3 の DOM 操作が衝突しない。
scale で値域を pixel に変換
棒グラフの場合、x 軸が カテゴリ(label)、y 軸が 連続値(value):
// x: カテゴリ → 等間隔の x 座標(scaleBand)
const x = d3.scaleBand<string>()
.domain(data.map((d) => d.label)) // ["1Q", "2Q", "3Q", "4Q"]
.range([0, INNER_W]) // 0px ↔ 364px
.padding(0.2); // bar 間の隙間
// y: 0 から data の最大値 → 縦方向の pixel(上下反転)
const y = d3.scaleLinear()
.domain([0, d3.max(data, (d) => d.value) ?? 0])
.nice() // 軸目盛をきりの良い数に
.range([INNER_H, 0]); // 注意: 高さは 0(下) → INNER_H(上)
y の range([INNER_H, 0]) が逆転しているのは、SVG は左上原点なので「画面下に行くほど y 座標が増える」から。値が大きい bar が「上に伸びる」ように見せるには、y 軸の range を反転する。
data join — enter / update / exit
data join(D3 の中心パターン)= 「データ配列の各要素に DOM 要素を 1 対 1 で対応付ける」考え方。data().join() で 新しい data に対して、DOM の rect 群を 3 通りに振り分ける:
- enter(新規):data にあるが DOM に無い → 新規 append
- update(継続):data にも DOM にもある → 属性更新
- exit(消滅):DOM にあるが data から消えた → remove
bars.selectAll<SVGRectElement, Datum>("rect")
.data(data, (d) => d.label) // key で identity を決める
.join(
(enter) => enter.append("rect") // 新 data: rect を新規作成
.attr("x", (d) => x(d.label))
.attr("y", INNER_H) // 初期は床から
.attr("height", 0)
.call((s) => s.transition().duration(500)
.attr("y", (d) => y(d.value))
.attr("height", (d) => INNER_H - y(d.value))),
(update) => update // 既存 data: 値を transition で更新
.call((s) => s.transition().duration(500)
.attr("y", (d) => y(d.value))
.attr("height", (d) => INNER_H - y(d.value))),
(exit) => exit // 消えた data: アニメで縮めて削除
.transition().duration(500)
.attr("height", 0)
.attr("y", INNER_H)
.remove(),
);
ポイント:
data(data, key)の key 関数 = identity。同じ key の data は update 扱い、新 key は enter、消えた key は exit- transition は enter/update/exit 各々で個別に設定:enter は「下から伸びる」、update は「現在位置から新位置に」、exit は「縮めて消える」、と異なる演出が組める
.call(...)で transition を chain:join の各分岐は selection を返すので、.call経由で transition 適用が綺麗
数値を tween でカウント補間
tween(tween animation の略、補間)= 始点と終点の間を t=0..1 で滑らかに繋ぐ仕組み。D3 の tween(name, fn) は 属性ではなく任意の DOM 操作を時間補間 できる。
「100 → 250 に増える」ような数値表示を、bar の transition と同期して 滑らかにカウントアップ させる:
bars.selectAll<SVGTextElement, Datum>("text.value")
.data(data, (d) => d.label)
.join(
(enter) => enter.append("text")
.attr("class", "value")
.text((d) => d.value),
(update) => update
.call((s) => s.transition().duration(500)
.tween("text", function (d) {
// 現在の表示値 → 新値 を補間する関数を返す
const interp = d3.interpolateNumber(Number(this.textContent ?? 0), d.value);
return (t) => { this.textContent = String(Math.round(interp(t))); };
})),
(exit) => exit.remove(),
);
tween("text", fn) は 属性ではなく任意の DOM 操作を時間補間する仕組み:
fnは data ごとに呼ばれ、(t: 0..1)を受け取る関数を返すd3.interpolateNumber(from, to)で「値の補間関数」を作る- transition 中、frame ごとに
fn(t)が呼ばれて textContent が動く
これは Recharts では 追加の motion ライブラリが必要 な領域。D3 単独で書ける。
軸(axis)を出す
D3 は 軸 component を関数として提供 する:
const xAxis = svg.append("g")
.attr("class", "x-axis")
.attr("transform", `translate(${MARGIN.left}, ${MARGIN.top + INNER_H})`);
xAxis.transition().duration(500)
.call(d3.axisBottom(x)) // ← x scale を渡すだけで軸が描かれる
.attr("color", "var(--color-ink-soft)");
d3.axisBottom(scale) / d3.axisLeft(scale) が scale を見て tick / label を自動生成。.transition().call(axis) で 軸 label の位置も滑らかに動く(scale が変わったらラベル位置も変わる)。
つまずいたポイント
useEffect内でd3.select(ref)した瞬間にnullで empty selection:ref が確実に attach された後で動く必要があるが、useEffect は mount 後に走るので OK。ただし strict mode の double-render で初回 effect が cleanup → 再実行されるので、副作用が冪等(data join なら冪等)である必要- 依存配列に
[data]を入れたが update しない:data 配列の参照が変わってないのに中身を mutate している。新しい配列を作って setState する(setData([...newData])) - transition が走らない:
.transition().duration(...)を chain せずに直接.attr(...)してる。transition を attr の前に挟む - enter / update / exit を 1 つの
.attr(...)で書こうとして見た目が崩れる:enter は「初期値を 0 から」、update は「現在値から新値へ」、exit は「縮める」と挙動が違う。join の 3 引数で個別に書く - TypeScript 型がうるさい:
d3.select<SVGRectElement, Datum>のように 2 つの generic が必要。selection の DOM 型 + data 型を毎回明示 - 複数 chart を同 page で書くと state がぶつかる:D3 のコードが SVG ノードに直接書き込むので、
useRefを chart ごとに分ける viewBoxを指定せずにwidth="100%"で曲がる:viewBox="0 0 W H"と CSS の width を分離(viewBox = 内部座標系、CSS width = 表示サイズ)
評価
| 観点 | 評価 | コメント |
|---|---|---|
| 自由度 | ◎ | SVG / Canvas を直接書ける、できないことはほぼ無い |
| 学習コスト | △ | selection / scale / data join / tween など独特の概念 |
| TypeScript 型 | ○ | 公式型は揃っているが generic 多用、補完だけでは詰まる |
| バンドル | ○ | 必要 module だけ import すれば 30-50KB 程度(d3-selection + d3-scale + d3-transition 等) |
| React との協調 | △ | 境界設計を間違えると DOM の責務が曖昧になる |
向く / 向かないケース
- 向く: Recharts で詰まる具体カスタマイズ(独自 transition / 軸装飾 / 数値 tween / network 図)
- 向く: 静的 SVG を生成する一回限りの可視化(article inline 図など)
- 向かない: 標準的な dashboard(Recharts で十分、D3 は overkill)
- 向かない: 大規模可視化(数万要素以上 → Canvas / WebGL ベースの visx, deck.gl 等)
関連 Topic / 関連書籍
この記事と関係する tech-book.net の Topic と、それぞれの Topic に紐づく書籍:
React Native+Expoではじめるスマホアプリ開発 : JavaScriptによるアプリ構築の実際
「React Native」は、Facebookが開発しているスマートフォンアプリ向けの開発環境です。ほとんどのコードをJav…
Reactハンズオンラーニング 第2版 : Webアプリケーション開発のベストプラクティス
Webフロントエンドの「今」を学びたい人へ! Facebookが開発したJavaScrip…
Reactビギナーズガイド : コンポーネントベースのフロントエンド開発入門
FacebookのエンジニアによるReactの入門書! ReactによるコンポーネントベースのWebフロントエンド開発の…
TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発
新しいフロントエンドの入門書決定版! 本書はReact/Next.jsとTypeScriptを用いてWebアプリケーションを開…