はじめに
React でリスト表示を実装していて、「なんだかスクロールがカクつく」「カードをクリックした後の反応が遅い」と感じたことはありませんか?
リストのパフォーマンス問題は、React 開発で最もよく遭遇する課題の一つです。特にカード型 UI でリスト表示をしていると、1つのカードを更新しただけなのにリスト全体が再レンダリングされる、という事態が起きがちです。
私は2つの異なるプロダクトで、ほぼ同じパフォーマンス問題に遭遇しました。1回目は Wakulier(フリーランス向け依頼管理ツール)の MVP 開発中。2回目は IdeaSpool(アイデア管理ツール)の Chrome パフォーマンス最適化フェーズ。
面白いのは、2回目に遭遇したとき「ああ、またこれか」で終わらなかったことです。React.memo だけでは解決しない、もっと根深い原因が隠れていました。
この記事では、2つのプロダクトでの実体験をもとに、リスト表示のパフォーマンス改善で学んだことを共有します。「React.memo を入れればいい」という単純な話ではない、ということが伝われば幸いです。
最初の遭遇:Wakulier の MVP 開発
背景
Wakulier は、フリーランスのクリエイターが依頼を管理するためのツールです。2025年12月、Phase 1 の MVP を完成させるタイミングで、パフォーマンスの問題に気づきました。
依頼一覧画面では、クライアントからの依頼がカード形式で並んでいます。依頼のステータスを更新したり、料金を編集したりするたびに、リスト全体がちらつくように見えました。
機能としては動いている。でも「もっさり感」がある。MVP とはいえ、この体感速度では使い続けてもらえないかもしれないと考えました。
React.memo の適用
2025年12月28日の開発日記にはこう書きました:
React.memo: リスト表示でのカード再レンダリングを防止。体感速度が大幅に向上
原因はシンプルでした。親コンポーネントの state が更新されるたびに、すべての子コンポーネント(カード)が再レンダリングされていたのです。
React のデフォルトの挙動として、親が再レンダリングされると子もすべて再レンダリングされます。これは React の設計上意図された動作ですが、リスト表示のように多数のコンポーネントが並ぶ場面では、パフォーマンスへの影響が無視できなくなります。
対処法として、Claude Code にカードコンポーネントを React.memo でラップするよう依頼しました。考え方はこうです:
// React.memo でラップする前
const RequestCard = ({ request, onUpdate }) => {
return (
<div className="card">
<h3>{request.title}</h3>
<p>{request.clientName}</p>
{/* ... */}
</div>
);
};
// React.memo でラップした後
const RequestCard = React.memo(({ request, onUpdate }) => {
return (
<div className="card">
<h3>{request.title}</h3>
<p>{request.clientName}</p>
{/* ... */}
</div>
);
});
コードの変更としてはごくわずかです。しかし、この一行の違いが体感速度を大きく変えました。
同じ日の開発日記には、こうも書いています:
パフォーマンス最適化は体感速度に直結する
計測ツールで数値を比較したわけではありません。でも、画面を操作したときの「引っかかり」が明らかに減った。開発者としてではなく、ユーザーとして使ったときに「速くなった」と感じられたのは大きかったです。
useCallback との組み合わせ
React.memo だけでは不十分なケースがあります。それは、親コンポーネントからコールバック関数を props として渡している場合です。
親が再レンダリングされるたびに、インラインで定義した関数は新しい参照が生成されます。React.memo は props の参照が変わると再レンダリングを許可するため、せっかくの memo が無意味になります。
// ❌ これだと React.memo が効かない
const RequestList = ({ requests }) => {
const handleUpdate = (id, data) => {
// 更新処理
};
return requests.map((req) => (
<RequestCard
key={req.id}
request={req}
onUpdate={handleUpdate} // 毎回新しい参照
/>
));
};
// ✅ useCallback で参照を安定させる
const RequestList = ({ requests }) => {
const handleUpdate = useCallback((id, data) => {
// 更新処理
}, []);
return requests.map((req) => (
<RequestCard
key={req.id}
request={req}
onUpdate={handleUpdate} // 参照が安定する
/>
));
};
Wakulier ではこの React.memo + useCallback の組み合わせで、MVP のパフォーマンス問題は解消しました。
この時点では「リスト表示の最適化は React.memo と useCallback でカバーできる」という認識でした。数週間後、その認識が甘かったことを思い知ることになります。
2回目の遭遇:IdeaSpool の Chrome パフォーマンス問題
問題の発覚
IdeaSpool は、アイデアを記録・分析するためのツールです。2026年1月、Phase 4.9 として Chrome でのパフォーマンス問題に取り組むことになりました。
2026年1月21日の開発日記にはこう書きました:
アイデアリストの再レンダリングでカード全体が更新されていた
「また同じ問題か」と思うかもしれません。私も最初はそう思いました。Wakulier で学んだ React.memo のパターンを適用すれば解決するだろう、と。
しかし、IdeaSpool の問題はもっと複雑でした。React.memo を適用しても、Chrome での体感速度は思ったほど改善しなかったのです。
React.memo だけでは足りなかった
IdeaCard に React.memo を適用した後も、開発者ツールの Performance タブを確認すると、まだ不要な処理が走り続けていることがわかりました。
ここからが Wakulier のときとは違う展開です。再レンダリング以外の原因を探る必要がありました。
Claude Code と一緒に調査した結果、3つの追加原因が見つかりました。
React.memo の「外」にあった問題
1. animate-pulse による常時 60fps 再描画
IdeaSpool では、データ読み込み中のスケルトン UI に Tailwind CSS の animate-pulse を使っていました。これは要素をふわっと明滅させるアニメーションで、ローディング中の見た目としてはよくあるパターンです。
問題は、このアニメーションが「常時」動き続けていたことです。
animate-pulse は CSS アニメーションとして実装されており、要素が画面上にある限り 60fps で再描画を続けます。ローディングが終わった後も、何らかの条件でスケルトン要素が DOM に残っていると、ブラウザは毎秒60回の再描画を続けることになります。
1つや2つなら影響は軽微です。しかし、リスト表示で複数のカードにスケルトンが残っていると、その数だけ再描画コストが積み重なります。
対処法は単純でした。不要になった animate-pulse を確実に削除すること。ローディング完了後にスケルトン要素自体を DOM から取り除くようにしました。
この修正は地味ですが、効果は想像以上でした。Chrome の Performance タブで見ると、常時発生していた paint イベントが大幅に減少したのです。
2. setTimeout のクリーンアップ漏れ
2つ目の問題は、メモリリークにつながる setTimeout のクリーンアップ漏れでした。
IdeaSpool では、一部の UI フィードバック(操作完了のトースト表示など)に setTimeout を使っていました。コンポーネントがアンマウントされた後も setTimeout が実行されると、存在しない state を更新しようとしてメモリリークが発生します。
React ではよく知られた問題ですが、見落としやすいポイントでもあります。
// ❌ クリーンアップなし(メモリリーク)
useEffect(() => {
const timer = setTimeout(() => {
setShowToast(false);
}, 3000);
// return がない → タイマーが残り続ける
}, []);
// ✅ クリーンアップあり
useEffect(() => {
const timer = setTimeout(() => {
setShowToast(false);
}, 3000);
return () => clearTimeout(timer);
}, []);
コードの差は return () => clearTimeout(timer) の1行だけです。しかし、この1行がないと、ユーザーがページを素早く行き来するたびにタイマーが蓄積していきます。
リスト表示で各カードがタイマーを持っている場合、スクロールで画面外に出たカードのタイマーがクリーンアップされないまま残り続けます。カードの数が多いほど、メモリリークの影響は大きくなります。
3. IndexedDB クリーンアップの過剰実行
3つ目は IdeaSpool 固有の問題でした。IdeaSpool は PWA としてオフライン対応しており、データの一部を IndexedDB にキャッシュしています。
このキャッシュのクリーンアップ処理が、必要以上に頻繁に実行されていました。ブラウザタブがフォーカスされるたびにクリーンアップが走り、その間メインスレッドがブロックされていたのです。
対処法として、クリーンアップ処理にスロットリングを適用しました。一定時間以内の連続実行を抑制することで、不要な処理負荷を削減しました。
これも React.memo とは直接関係のない最適化ですが、リスト表示の体感速度に確実に影響していました。
複合的なアプローチの効果
IdeaSpool での最適化は、結局4つの修正の組み合わせになりました:
- React.memo の適用 — IdeaCard の不要な再レンダリングを防止
- animate-pulse の削除 — 常時 60fps の CSS 再描画を停止
- setTimeout のクリーンアップ — メモリリークの防止
- IndexedDB クリーンアップのスロットリング — メインスレッドのブロックを軽減
どれか1つだけでは、体感速度の改善は限定的だったと思います。4つを組み合わせたことで、Chrome でのパフォーマンス問題は解消しました。
開発日記にはこう記録しています:
React.memo は Props が変わらないコンポーネントに効果的
この一文の裏側には「React.memo だけでは不十分だった」という経験が含まれています。props が変わらない場合に効果的、ということは、それ以外の原因にも目を向ける必要がある、ということです。
React.memo を使うべきとき、使わなくてよいとき
2つのプロダクトでの経験を踏まえて、React.memo の適用判断基準をまとめます。
効果が大きいケース
- リスト表示のアイテムコンポーネント: 今回の主題。親の state 変更で全アイテムが再レンダリングされるのを防げる
- props が安定しているコンポーネント: 一度渡されたら変わらない props を受け取るコンポーネントに有効
- レンダリングコストが高いコンポーネント: 内部で複雑な計算や多くの子要素を持つ場合
効果が薄いケース
- props が毎回変わるコンポーネント: memo の比較コストだけが増える
- children を受け取るコンポーネント: JSX の children は毎回新しい参照になるため、memo が効きにくい
- レンダリングコストが低い単純なコンポーネント: memo の比較コストの方が高くつくことがある
重要なのは、React.memo は「銀の弾丸」ではないということです。Wakulier では React.memo だけで十分でしたが、IdeaSpool では CSS アニメーション、メモリリーク、IndexedDB といった React の外側にある原因も対処する必要がありました。
パフォーマンス問題を調査するときのチェックリスト
2つのプロダクトでの経験を通じて、私なりの調査手順ができました。同じような問題に直面している方の参考になるかもしれません。
- React DevTools の Profiler でレンダリング回数を確認 — 不要な再レンダリングがないかを最初にチェック
- Chrome DevTools の Performance タブで全体像を把握 — CPU 使用率が高い区間がないか確認
- CSS アニメーションの確認 —
animate-pulseやtransitionが不要に動き続けていないか - useEffect 内のクリーンアップ漏れ — setTimeout, setInterval, addEventListener の解除忘れ
- 非同期処理のスロットリング — API コールやストレージ操作が過剰に実行されていないか
ポイントは、1番と2番の順序です。最初に React の再レンダリングだけを見て React.memo を適用し、「まだ遅い」と悩む前に、ブラウザ全体のパフォーマンスを俯瞰することをお勧めします。
学んだこと
パフォーマンス最適化は「体感」がすべて
Wakulier の開発日記に書いた「パフォーマンス最適化は体感速度に直結する」という言葉は、今でも実感しています。
Lighthouse のスコアや React Profiler の数値ももちろん重要です。しかし、最終的にユーザーが判断するのは「速いか遅いか」という体感です。数値上の改善が体感に反映されていないなら、まだ別の原因が隠れている可能性があります。
IdeaSpool の場合、React.memo を適用した時点で React Profiler 上の再レンダリング回数は減っていました。でも体感では「まだ遅い」と感じた。そこで Chrome の Performance タブを開いてみたら、CSS アニメーションとメモリリークという React の外側の問題が見つかりました。
計測ツールの数値だけでなく、実際に操作してみる。当たり前のことですが、つい忘れがちなポイントです。
同じ問題でも文脈が違えば解決策も違う
Wakulier と IdeaSpool で遭遇した問題は、表面上は同じ「リスト表示のパフォーマンス問題」でした。しかし、Wakulier は React.memo + useCallback で解決したのに対し、IdeaSpool では4つの修正が必要でした。
違いはプロダクトの特性にあります。IdeaSpool は PWA であり、オフライン対応のための IndexedDB キャッシュや、スケルトン UI のための CSS アニメーションなど、Wakulier にはない要素がありました。
「前回はこれで解決したから今回も同じだろう」という思い込みは危険です。問題の症状が似ていても、原因は異なることがある。2回目の遭遇で学んだ最大の教訓です。
React.memo は入口であり、ゴールではない
React のパフォーマンス最適化というと、まず React.memo が挙がります。それ自体は正しいのですが、React.memo は「不要な再レンダリングを防ぐ」という1つの側面に過ぎません。
ブラウザのレンダリングパイプラインは、JavaScript の実行(React の再レンダリング)だけでなく、Style 計算、Layout、Paint、Composite と多くのステージで構成されています。React.memo が対処するのは最初の JavaScript 実行の部分だけです。
今回の IdeaSpool のケースでは、animate-pulse が Paint ステージに、IndexedDB クリーンアップがメインスレッド全体に影響していました。React の外にも目を向けることで、初めてパフォーマンス問題の全体像が見えてきます。
おわりに
React.memo は、リスト表示のパフォーマンス改善において強力なツールです。Wakulier でも IdeaSpool でも、最適化の出発点として欠かせない存在でした。
ただし、React.memo だけで問題が解決するとは限りません。IdeaSpool での経験が教えてくれたのは、パフォーマンスの問題は複合的であることが多い、ということです。CSS アニメーション、タイマーのクリーンアップ、非同期処理のスロットリングなど、React の外側に目を向けることで、体感速度は着実に改善できます。
もしリストのパフォーマンスに悩んでいるなら、まず React.memo を試してみてください。それで解決すれば万々歳です。まだ遅いと感じたら、Chrome DevTools の Performance タブを開いて、React の外で何が起きているかを確認してみてください。答えはそこにあるかもしれません。
関連記事:
- @dnd-kit/sortable でドラッグ&ドロップ並び替えを実装する — リスト表示に関連する UI 実装パターン
- モバイルファーストなモーダル設計 — フルスクリーンシートの実装 — モバイルでの UI パフォーマンスに関連
Zeronova(ゼロノバ)
Product Manager / AI-Native Builder
Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。