パフォーマンス最適化とメモ化
そもそも「再レンダリング」はいつ起きる?
Reactコンポーネントは、ざっくり言うと次のときに再レンダリングされます:
- 自分のstateが変わったとき
- 親が再レンダリングされて、自分へのpropsが変わったとき
- (コンテキスト使用時)Contextのvalueが変わったとき
ここで重要なのは、親が再レンダリングされると「子も一旦レンダリングされる」 のが基本動作ということです。
function Child({ value }) {
console.log('Child render')
return <p>{value}</p>
}
export default function App() {
const [count, setCount] = useState(0)
console.log('App render')
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
<Child value="fixed" />
</div>
)
}ボタンを押すたびに、App renderとChild renderの両方がログに出ます(propsが変わってなくても)。
ほとんどのケースではこれで問題ないですが、子コンポーネントで重い計算をしていたり、子が大量に並んでいるリストだったりすると、「毎回全部レンダリング」は重たくなります。
そこで登場するのが メモ化 の機能です。
React.memo:propsが変わらなければ再レンダリングしない
React.memo は コンポーネントをラップする高階コンポーネント で、「同じpropsなら前回の結果を使い回して、レンダリングをスキップする」という挙動をしてくれます。
基本形
import React from 'react'
function Child({ value }) {
console.log('Child render')
return <p>{value}</p>
}
// メモ化
export default React.memo(Child)もしくは:
const Child = React.memo(function Child({ value }) {
// ...
})使い方:
// App.jsx
import { useState } from 'react'
import Child from './Child' // React.memo済み
export default function App() {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
<Child value="固定テキスト" />
</div>
)
}count を変えても Child の value は変わらないので、React.memo によって Child の再レンダリングがスキップされます。
ただし props の「浅い比較」で判断
React.memo が比較するのは shallow equal(浅い比較) です。
- プリミティブ(number, string, boolean)は値比較
- オブジェクト / 配列 / 関数は「参照比較」
つまり、以下のケースでは毎回「違う」と判定されます:
<Child obj={{ a: 1 }} />毎レンダリングで { a: 1 } という新しいオブジェクトが生成されるため、React.memo は「前回と違うprops」と判定して再レンダリングします。
この話は後の useMemo / useCallback に繋がってきます。
useMemo:重い計算結果やオブジェクトをメモ化する
useMemo は「重い計算」や「依存するstateから導出される値」をメモ化するためのフックです。
シグネチャ:
const memoizedValue = useMemo(() => {
// 重い計算
return 結果
}, [依存値...])Reactの解釈:
- 依存配列の中の値が変わらない限り、前回計算した結果をそのまま返す
- 依存値が変わったときだけコールバックを再実行して新しい値を計算
重い計算のメモ化
import { useMemo, useState } from 'react'
function heavyCalc(n) {
console.log('heavyCalc 実行')
let sum = 0
for (let i = 0; i < 1_000_000_0; i++) {
sum += i % n
}
return sum
}
export default function App() {
const [n, setN] = useState(10)
const [count, setCount] = useState(0)
const result = useMemo(() => heavyCalc(n), [n])
return (
<div>
<p>heavyCalc結果: {result}</p>
<button onClick={() => setN((v) => v + 1)}>nを増やす</button>
<hr />
<p>カウンタ: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
</div>
)
}ここでのポイント:
countが変わって再レンダリングしても、[n]が変わってない限りheavyCalcは実行されない(ログにも出ない)nが変わったときだけ重い計算をやり直す
「オブジェクト/配列props」を安定させるための useMemo
先ほどの React.memo の話を思い出すと、子コンポーネントにオブジェクト/配列propsを渡すとき、
親が毎回新しいオブジェクト/配列を作ると、memoの効果が消えるという問題がありました。
これを防ぐために useMemo でオブジェクトを包むことがあります。
const options = useMemo(
() => ({
page,
pageSize: 10,
}),
[page],
)
return <List options={options} />こうすることで、
pageが変わらない限りoptionsの参照も同じReact.memo(List)していれば、他のpropsが変わらない限り再レンダリングをスキップ
useCallback:関数の「identity(同一性)」を安定させる
useCallback は「関数をメモ化する」=「毎回新しい関数を作らないようにする」ためのフックです。
シグネチャは useMemo とほぼ同じ:
const memoizedFn = useCallback(() => {
// 関数本体
}, [依存値...])実際のところ、下記コードのシンタックスシュガーです。
useCallback(fn, deps) === useMemo(() => fn, deps)子コンポーネントに渡すイベントハンドラを安定化
function Child({ onClick }) {
console.log('Child render')
return <button onClick={onClick}>子ボタン</button>
}
const MemoChild = React.memo(Child)
export default function App() {
const [count, setCount] = useState(0)
const handleClick = () => {
console.log('clicked')
}
return (
<div>
<p>count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
<MemoChild onClick={handleClick} />
</div>
)
}このままだと:
AppがレンダリングされるたびにhandleClickという新しい関数が生成されるonClickprops の参照が毎回変わる ⇒MemoChildは毎回再レンダリングされる
ここで useCallback を使う:
const handleClick = useCallback(() => {
console.log('clicked')
}, []) // 依存がないので常に同じ関数参照こうすると、
Appが何度レンダリングされてもhandleClickの参照は固定MemoChildも propsが変わらない限り再レンダリングされない
依存配列にstateを入れるパターン
const [count, setCount] = useState(0)
const handleChildAction = useCallback(() => {
// count を使いたい
console.log('current count', count)
}, [count])countが変わるたびに、新しい関数が生成される(必要)- それ以外の再レンダリングでは、同じ関数参照が使われる
※ 依存配列に本来入れるべき値を入れ忘れると、古い値を閉じ込めた関数になってしまうので注意。
「メモ化すればするほど速くなる」わけではない
ここまで読むと、「全部 useMemo と useCallback で包めばいいのでは?」と思いたくなりますが、これはアンチパターンです。
理由:
useMemo/useCallback自体にもオーバーヘッドがある- 依存配列の比較・キャッシュ管理もタダではない
- 小さなコンポーネントでの再レンダリングはそもそも軽いことが多い
実務での指針はだいたいこんな感じです:
- まずは何も最適化せずに書く
- 体感で「重いな」と思ったり、実際にパフォーマンス問題が出てきたら、どこがボトルネックかを特定する
- そこにピンポイントで
React.memo/useMemo/useCallbackを入れる
つまり、「なんとなく不安だから全部useCallback」は逆に遅くなる可能性もあるということです。
「派生state」と useMemo:stateに持つか、計算で済ませるか
Reactではよく、「他のstateから計算で出せる値は、なるべく新しいstateにしない」という原則が語られます。
例:Todoの完了数
const [todos, setTodos] = useState([...])
// const [doneCount, setDoneCount] = useState(0) ← これは不要なstateになりがち
const doneCount = todos.filter((t) => t.done).lengthdoneCount を別stateで持つと、
todos更新ロジックのすべてでsetDoneCountを正しく呼ばないといけない- バグの元(非整合)
なので、「Todosが変わるたびに毎回計算しても問題ない程度の重さなら、計算で済ませる」ほうが安全です。
もし
todosが数千〜数万件あってfilterが明らかに重い
というくらいになってきたら、そのとき初めて useMemo を考えればOKです。
const doneCount = useMemo(
() => todos.filter((t) => t.done).length,
[todos],
)「どこから最適化すべきか?」の思考プロセス
まとめとして、最適化を考えるときの思考プロセスを整理しておきます。
- まずはプロファイル or 目視で「どの画面が重いか」を把握
- React DevTools の Profiler やブラウザのPerformanceタブ
- あるいは単純に「このリストスクロールすると重いな」でもOK
- その画面のどのコンポーネントが頻繁に再レンダリングされているかを見る
- コンソールログで
renderを出してもよい - 大量ループしているコンポーネント・重い計算をしているコンポーネントを特定
- コンソールログで
- ピンポイントに手を入れる:
- 大量の子を持つ親コンポーネントの子を
React.memoでメモ化 - 子へのpropsに渡しているオブジェクト・関数を
useMemo/useCallbackで安定化 - 重い計算に
useMemoを適用
- 大量の子を持つ親コンポーネントの子を
- 効果を測る:
- レンダリング回数が減ったか
- 体感速度が改善したか
このステップを踏めば、「闇雲なuseCallback地獄」に陥ることはだいぶ避けられます。
遅延ロード(lazy / Suspense)で初期表示を軽くする
なぜ「遅延ロード」が必要なのか
SPA では、最初に読み込む JavaScript が大きくなりがちです。
初期表示に不要な画面・重いコンポーネント(グラフ・マップ・リッチエディタ・管理画面など)まで全部まとめてバンドルされると、
- 初回ロードが遅い
- 白画面が長い
- 体感もSEOも悪化
という状態になります。
そこでやりたいのが、「ユーザーがその機能を使おうとした そのとき にだけ、そのコードを読み込む」
= コード分割(Code Splitting)+遅延ロード です。
Reactではこれを React.lazy と Suspense で行います。
React.lazy の基本
書き方のパターンはほぼこれ一つです。
import React, { Suspense } from 'react'
// ① 遅延ロードしたいコンポーネント
const HeavyComponent = React.lazy(() => import('./HeavyComponent'))
export default function App() {
return (
<Suspense fallback={<p>読み込み中...</p>}>
<HeavyComponent />
</Suspense>
)
}ポイントは:
React.lazy(() => import('./HeavyComponent'))- 動的
import()を使うことで、ビルド時に自動的にファイルが分割される HeavyComponentは最初のバンドルには含まれず、「必要になったとき」に別チャンクとして取得される
- 動的
<Suspense fallback={...}>HeavyComponentの JS を読み込み中のあいだだけ、fallbackを表示する- ローディングスピナーや「読み込み中…」テキストなどを置く
注意:lazy の対象モジュールは export default が必要です。
ルート単位・部分単位での遅延ロード
画面(ルート)ごとに遅延ロード
画面数が増えてきたら、ルーティング単位で lazy にするのが定番です。
const HomePage = React.lazy(() => import('./pages/HomePage'))
const DashboardPage = React.lazy(() => import('./pages/DashboardPage'))
export function AppRoutes() {
return (
<Suspense fallback={<p>画面読み込み中...</p>}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/dashboard" element={<DashboardPage />} />
</Routes>
</Suspense>
)
}- 初回は
HomePageのチャンクだけ読み込む /dashboardに遷移したときに、必要になった分だけ追加で取得する
これだけで、「使わない画面のJSを最初から読み込まない」 状態になります。
ページの一部だけを遅延ロード
suspense はネスト可能なので、ページ全体ではなく一部だけ遅延ロードするのもOKです。
<Suspense fallback={<p>グラフ読み込み中...</p>}>
<ChartPanel />
</Suspense>- 重いグラフだけ後読み
- ヘッダー・リストなどは先に表示
- 「グラフ読み込み中…」と「一覧だけ先に表示」のような体験にできる
isLoading とどう違うのか
API のローディング表示(useState で loading を管理)との違いは:
loading state
⇒ データ取得中 を表すSuspense
⇒ コンポーネントのコード(JSファイル)自体を読み込み中 を表す
つまり、Suspense は「コードがまだ届いていない」状態のためのローディング、loading state は「データが返ってきていない」状態のためのローディングです。
どちらも UI 上は似たように見えますが、役割が違います。
いつ使うべきかの目安
遅延ロードのターゲットとしては、主に次のようなものが有力です。
- あまり頻繁には開かれない画面
/settings,/admin,/reportなど
- 明らかに重いコンポーネント
- グラフ・マップ・リッチエディタ
- モーダルや詳細ダイアログ
- 一覧画面は軽いままにしておき、詳細だけ後読み
逆に、
- 小さいフォーム
- 共通ヘッダー/フッター
- 小さな部品コンポーネント
などは、無理に lazy にしなくても OK です(チャンク分割しすぎても逆効果)。
まとめ
- Reactは「親が再レンダリングされると子も一旦レンダリングされる」が基本動作
React.memoは「同じpropsなら再レンダリングをスキップ」するためのラッパ- ただし、浅い比較なので、オブジェクト/配列/関数は毎回新しいと意味が薄れる
useMemoは- 重い計算結果やオブジェクトをメモ化する
- 依存配列が変わったときだけ再計算
useCallbackは- 関数の参照を安定させるためのフック
- memo化した子コンポーネントに渡すコールバック用に使うと効果的
- 「なんとなく全部メモ化」は逆効果になりうる
- まずは素直に書いて、問題が出た箇所にだけピンポイントで適用する
- 他のstateから計算で出せる値は、なるべく新しいstateにせず、必要なら
useMemoで派生させるほうが安全 React.lazyでコンポーネントを動的 import すると、そのコンポーネントが別チャンクとして後読みされるSuspenseのfallbackで「読み込み中のあいだに表示するUI」を指定できる- ルーティング単位・部分単位で使い分けることで、
- 初期表示バンドルを軽くする
- UX を保ったまま重い機能を後ろに追い出せる
- データ用のローディング(
loading state)とは役割が違うが、組み合わせて使うとよりリッチな体験になる
コメント