Reactで、並べ替えの動きを棒グラフで見せる方法
並べ替えの途中の様子を、棒グラフで1手ずつ見せる部品を React で作りました。2本の棒を比べるたびに色を変え、入れ替えるときは棒が横にすべって位置が替わります。再生と一時停止、1手ずつ進めるボタンも付けています。
作り方の要点は2つです。1つ目は、並べ替えを先に最後まで実行して、1手ごとの並びを配列に記録しておくことです。画面は、その配列の何番目を表示するかだけを state に持ちます。2つ目は、棒を動かすときの DOM の扱いです。key の付け方と DOM の並べ方によっては、棒がすべらずに一瞬で入れ替わります。その違いは、検証環境で棒の位置を測って確かめました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。並べ替えの方法はバブルソートだけを扱います。Firefox と Safari、スマートフォン、スクリーンリーダーでの読み上げは試していません。

GIF のカーソル、クリックの輪、下の帯の表示は、撮影のために重ねたものです。回数の表示が変わった瞬間だけピンクにする仕掛けも、撮影のときだけ足しています。撮影では、棒の色の切り替えに途中の色が写らないように、棒の背景色(background-color)の transition を外しています。
並べ替えの途中を、1手ずつ配列に記録する
筆者は、並べ替えを先に最後まで終わらせ、1回比べるごとの並びを Step として配列に残す作りにしました。画面のほうは、この配列の何番目を表示するかを変えるだけなので、一時停止も1手ずつ進める操作も、表示する番号を進めるのを止めるか、1つ進めるかだけで済みます。
export type Bar = { id: number; value: number };
export type Step = {
bars: Bar[]; // この時点での並び
comparing: [number, number] | null; // 比べている2本の位置
swapped: boolean; // この手で入れ替えたか
sortedFrom: number; // この位置から右は並べ終わっている
compares: number; // ここまでに比べた回数
swaps: number; // ここまでに入れ替えた回数
};
// バブルソートを最後まで進め、1回比べるごとの様子を配列にして返す
export function bubbleSortSteps(input: Bar[]): Step[] {
const bars = [...input];
const n = bars.length;
let compares = 0;
let swaps = 0;
const steps: Step[] = [
{ bars: [...bars], comparing: null, swapped: false, sortedFrom: n, compares, swaps },
];
for (let end = n - 1; end > 0; end--) {
for (let i = 0; i < end; i++) {
compares++;
const swapped = bars[i].value > bars[i + 1].value;
if (swapped) {
[bars[i], bars[i + 1]] = [bars[i + 1], bars[i]];
swaps++;
}
steps.push({ bars: [...bars], comparing: [i, i + 1], swapped, sortedFrom: end + 1, compares, swaps });
}
}
steps.push({ bars: [...bars], comparing: null, swapped: false, sortedFrom: 0, compares, swaps });
return steps;
}
// 1〜n の値を Fisher–Yates で混ぜた棒を作る
export function shuffledBars(n: number): Bar[] {
const values = Array.from({ length: n }, (_, i) => i + 1);
for (let i = n - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[values[i], values[j]] = [values[j], values[i]];
}
return values.map((value, id) => ({ id, value }));
}
棒には、値とは別に id を持たせています。同じ値の棒があっても見分けられるようにするためと、後で key に使うためです。bars は記録したあとも入れ替えを続けるので、記録するときは1手ごとに [...bars] で写しを作ります。
GIF と同じ 5 9 2 7 1 8 3 10 6 4 の並びを Node.js で渡すと、記録は次のようになりました。
手の数(最初と最後を含む): 47
比べた回数: 45 入れ替えた回数: 22 転倒数(別に数えた値): 22
最後の並び: 1 2 3 4 5 6 7 8 9 10
最初の5手:
5 9 2 7 1 8 3 10 6 4 null
5 9 2 7 1 8 3 10 6 4 [ 0, 1 ]
5 2 9 7 1 8 3 10 6 4 [ 1, 2 ] 入れ替え
5 2 7 9 1 8 3 10 6 4 [ 2, 3 ] 入れ替え
5 2 7 1 9 8 3 10 6 4 [ 3, 4 ] 入れ替え
5 2 7 1 8 9 3 10 6 4 [ 4, 5 ] 入れ替え
入力はそのまま: 5 9 2 7 1 8 3 10 6 4
比べた回数は、10本なら 10×9÷2 で45回です。本数を n とすると n×(n−1)÷2 で、30本・100本でもこの式と同じ回数でした。入れ替えた回数の22回は、左にあるほうが大きい組の数(転倒数)を、別のループで数えた値と一致しました。バブルソートは隣どうしの入れ替えで、この組を1つずつ解消していくからです。渡した配列は書き換わっていません。
1手ごとに並び全体を写すので、記録は本数が増えると急に大きくなります。JSON の文字列にした長さで比べると、10本で約1万3千文字、30本で約31万文字、100本では約1,076万文字でした。100本では4,952手になり、どの手も100本ぶんの並びを持つためです。
再生は useEffect のタイマーで1手ずつ進める
画面の部品は次のとおりです。いま表示している手の番号 index と、再生中かどうかの playing を state に持ちます。
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
import { bubbleSortSteps, shuffledBars, type Bar } from './sortSteps';
const SLOT = 32; // 1本ぶんの横幅(px)
const DELAY = 400; // 1手を表示しておく時間(ms)
type Props = { initial?: Bar[]; count?: number };
export function SortVisualizer({ initial, count = 10 }: Props) {
const [bars, setBars] = useState<Bar[]>(() => initial ?? shuffledBars(count));
const steps = useMemo(() => bubbleSortSteps(bars), [bars]);
const [index, setIndex] = useState(0);
const [playing, setPlaying] = useState(false);
const step = steps[index];
const done = index === steps.length - 1;
const max = Math.max(...bars.map((b) => b.value));
// 再生中は、DELAY ごとに1手ずつ進める
useEffect(() => {
if (!playing || done) return;
const timer = setTimeout(() => setIndex((i) => i + 1), DELAY);
return () => clearTimeout(timer);
}, [playing, done, index]);
const handlePlay = () => {
if (done) setIndex(0);
setPlaying(true);
};
const handleShuffle = () => {
setBars(shuffledBars(count));
setIndex(0);
setPlaying(false);
};
// id から「いま何番目にあるか」を引けるようにする
const position = new Map(step.bars.map((b, i) => [b.id, i]));
return (
<div className="sv">
<div className="sv-chart" style={{ width: bars.length * SLOT }}>
{/* DOM の並びは id の順のまま変えず、位置は transform だけで動かす */}
{bars.map((bar) => {
const i = position.get(bar.id)!;
const state =
step.comparing?.includes(i) ? 'comparing' : i >= step.sortedFrom ? 'sorted' : 'idle';
const style: CSSProperties = {
height: `${(bar.value / max) * 100}%`,
width: SLOT - 6,
transform: `translateX(${i * SLOT}px)`,
};
return (
<div key={bar.id} className="sv-bar" data-state={state} style={style}>
<span>{bar.value}</span>
</div>
);
})}
</div>
<p className="sv-status">
比べた回数 {step.compares}、入れ替えた回数 {step.swaps}
<br />
{done ? '並べ終わりました。' : step.swapped ? '入れ替えました。' : ''}
</p>
<div className="sv-buttons">
{playing && !done ? (
<button type="button" onClick={() => setPlaying(false)}>一時停止</button>
) : (
<button type="button" onClick={handlePlay}>{done ? 'もう一度再生' : '再生'}</button>
)}
<button type="button" onClick={() => setIndex((i) => i + 1)} disabled={playing || done}>
1手進める
</button>
<button type="button" onClick={handleShuffle}>並べ直す</button>
</div>
</div>
);
}
再生には setInterval ではなく、1手ごとに setTimeout を1つだけ仕掛ける書き方を使いました。依存配列に index を入れているので、1手進むたびに前のタイマーを片付けてから、次のタイマーを仕掛け直します。最後の手まで来ると done が true になり、タイマーを仕掛けなくなります。止めるための state の書き換えは要りません。
公式リファレンスの useEffect の説明にあるとおり、StrictMode では開発時だけ、マウントしたときにセットアップとクリーンアップが本物のセットアップの前に1回余分に実行されます。この部品はマウントの時点では playing が false なので、余分に実行されてもタイマーは仕掛けられません。再生を始めたあとは、index が変わるたびにクリーンアップの clearTimeout で前のタイマーを消してから、次のタイマーを仕掛けます。StrictMode で包んだ開発用のビルドで、再生中の表示が変わった時刻を記録すると、比べた回数は4、5、6、7、8と1つずつ増え、間隔は403〜404ms でした。一度に2手進むことはありませんでした。
一時停止を押したあとは、1.5秒待っても表示は「比べた回数 8」のままでした。最後まで再生すると「比べた回数 45、入れ替えた回数 22」「並べ終わりました。」と出て、10本すべてが並べ終えた色(濃い水色)になります。このとき「1手進める」は押せなくなり、再生のボタンは「もう一度再生」に変わります。押すと最初の並びに戻って再生が始まりました。
棒をすべらせるには、DOM の並びを変えない
棒は position: absolute で同じ場所に重ね、transform: translateX() で横の位置を決めています。CSS で transform に transition を付けておけば、位置が変わったときに棒がすべって動きます。
ここで気をつけるのが、棒を描く順番と key です。記事のコードでは、DOM の並びは bars(id の順)のまま変えず、いまの位置は transform にだけ反映しています。3通りの描き方を比べるために、3本の棒(9、3、6)の先頭の2本を入れ替えて、150ms 後の位置を getComputedStyle で読みました。動き終わったときの位置は、3 が 0px、9 が 32px です。
| 描き方 | 150ms 後の位置 | 動き方 |
|---|---|---|
key に配列の位置を使い、いまの並びの順に描く |
3=0px、9=32px | どちらも一瞬で入れ替わる |
key に id を使い、いまの並びの順に描く |
3=6.3px、9=32px | 3 はすべるが、9 は一瞬で移る |
key に id を使い、DOM は id の順のまま描く |
9=23.7px、3=8.3px | 2本ともすべる |
key に配列の位置を使うと、要素はそのまま残り、中の数字と高さだけが替わりました。棒が動いたのではなく、別の棒に描き替わったように見えます。公式ドキュメントの「リストのレンダー」のページでも、並び替えのある配列でインデックスを key に使うと、ややこしいバグの原因になると書かれています。
key に id を使い、いまの並びの順に描いた場合は、React が要素の1つ(ここでは 9)を DOM の中で移しました。検証環境では、DOM の中で移された 9 は一瞬で動き終わった位置に移り、もう1本の 3 だけがすべりました。入れ替えのたびに片方の棒が飛んで見えます。
DOM の並びを id の順に固定すると、要素は移されず、transform の値だけが変わります。2本とも途中の位置にあり、それぞれ反対の向きにすべっていました。GIF でも、入れ替える2本が重なりながらすれ違っています。
CSS は次のとおりです。比べている2本はピンク、並べ終えた棒は濃い水色にしています。
.sv { display: grid; gap: 12px; font-size: 15px; color: #1f2a30; }
.sv-chart { position: relative; height: 200px; border-bottom: 2px solid #1f2a30; }
.sv-bar {
position: absolute; left: 3px; bottom: 0;
display: flex; align-items: flex-end; justify-content: center;
background: #00c8e8; border-radius: 4px 4px 0 0;
transition: transform 300ms ease, background-color 150ms;
}
.sv-bar span { font-size: 12px; padding-bottom: 4px; }
.sv-bar[data-state='comparing'] { background: #d85878; }
.sv-bar[data-state='sorted'] { background: #067a98; color: #fff; }
.sv-status { margin: 0; min-height: 3em; line-height: 1.5; }
.sv-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
.sv-buttons button { padding: 8px 14px; font: inherit; }
@media (prefers-reduced-motion: reduce) {
.sv-bar { transition: none; }
}
transition の 300ms は、1手を表示しておく 400ms より短くしています。次の手に進む前に、すべる動きが終わるようにするためです。検証環境では、入れ替えから 550ms 後に、2本とも動き終わった位置(96px と 64px)に止まっていました。
最後の @media は、端末で動きを減らす設定をしている人のためのものです。MDN の prefers-reduced-motion の説明にあるとおり、この設定は、動きのあるアニメーションを減らしたい、という利用者の希望をブラウザに伝えます。Playwright でこの設定にして、上の3通りの描き方で同じように入れ替えると、150ms 後には3通りとも動き終わった位置にあり、棒はすべらずに入れ替わりました。この @media で止めているのは transition だけなので、比べている棒の色と回数の表示は、設定に関係なく1手ごとに変わります。





