[React講座] パフォーマンス最適化とメモ化

当ページのリンクには広告が含まれています。
目次

パフォーマンス最適化とメモ化

そもそも「再レンダリング」はいつ起きる?

Reactコンポーネントは、ざっくり言うと次のときに再レンダリングされます:

  1. 自分のstateが変わったとき
  2. 親が再レンダリングされて、自分へのpropsが変わったとき
  3. (コンテキスト使用時)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 renderChild 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 を変えても Childvalue は変わらないので、
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 という新しい関数が生成される
  • onClick props の参照が毎回変わる ⇒ 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 自体にもオーバーヘッドがある
  • 依存配列の比較・キャッシュ管理もタダではない
  • 小さなコンポーネントでの再レンダリングはそもそも軽いことが多い

実務での指針はだいたいこんな感じです:

  1. まずは何も最適化せずに書く
  2. 体感で「重いな」と思ったり、実際にパフォーマンス問題が出てきたら、どこがボトルネックかを特定する
  3. そこにピンポイントで 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).length

doneCount を別stateで持つと、

  • todos 更新ロジックのすべてで setDoneCount を正しく呼ばないといけない
  • バグの元(非整合)

なので、「Todosが変わるたびに毎回計算しても問題ない程度の重さなら、計算で済ませる」ほうが安全です。

もし

  • todos が数千〜数万件あって
  • filter が明らかに重い

というくらいになってきたら、そのとき初めて useMemo を考えればOKです。

const doneCount = useMemo(
  () => todos.filter((t) => t.done).length,
  [todos],
)

「どこから最適化すべきか?」の思考プロセス

まとめとして、最適化を考えるときの思考プロセスを整理しておきます。

  1. まずはプロファイル or 目視で「どの画面が重いか」を把握
    • React DevTools の Profiler やブラウザのPerformanceタブ
    • あるいは単純に「このリストスクロールすると重いな」でもOK
  2. その画面のどのコンポーネントが頻繁に再レンダリングされているかを見る
    • コンソールログで render を出してもよい
    • 大量ループしているコンポーネント・重い計算をしているコンポーネントを特定
  3. ピンポイントに手を入れる:
    • 大量の子を持つ親コンポーネントの子を React.memo でメモ化
    • 子へのpropsに渡しているオブジェクト・関数を useMemo / useCallback で安定化
    • 重い計算に useMemo を適用
  4. 効果を測る:
    • レンダリング回数が減ったか
    • 体感速度が改善したか

このステップを踏めば、「闇雲なuseCallback地獄」に陥ることはだいぶ避けられます。

遅延ロード(lazy / Suspense)で初期表示を軽くする

なぜ「遅延ロード」が必要なのか

SPA では、最初に読み込む JavaScript が大きくなりがちです。
初期表示に不要な画面・重いコンポーネント(グラフ・マップ・リッチエディタ・管理画面など)まで全部まとめてバンドルされると、

  • 初回ロードが遅い
  • 白画面が長い
  • 体感もSEOも悪化

という状態になります。

そこでやりたいのが、「ユーザーがその機能を使おうとした そのとき にだけ、そのコードを読み込む」

= コード分割(Code Splitting)+遅延ロード です。

Reactではこれを React.lazySuspense で行います。

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 のローディング表示(useStateloading を管理)との違いは:

  • 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 すると、そのコンポーネントが別チャンクとして後読みされる
  • Suspensefallback で「読み込み中のあいだに表示するUI」を指定できる
  • ルーティング単位・部分単位で使い分けることで、
    • 初期表示バンドルを軽くする
    • UX を保ったまま重い機能を後ろに追い出せる
  • データ用のローディング(loading state)とは役割が違うが、組み合わせて使うとよりリッチな体験になる

<<前へ(Reactのスタイリング手法)

>>次へ(テストとデバッグ)

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次