React・仕事で使うために

再描画のしくみとmemo・useMemo・useCallback

Reactがいつ描き直すのかを整理してから、memo・useMemo・useCallbackの3つを実測つきで見ていきます。memoだけ付けても効かない理由、先回りして最適化してはいけない理由、React Compilerで何が変わるのかまで扱います。

ここまでは「動くものを作る」話でした。この回からは仕事で使うための話に入ります。まずは、Reactがいつ画面を描き直すのかです。

描き直される4つのきっかけ

コンポーネントが再描画されるのは、次のどれかが起きたときです。

きっかけ
自分のstateが変わったsetKeyword('…')
受け取っているpropsが変わった親が違う値を渡してきた
読んでいるContextの値が変わったuseBirds()の中身が更新された
親が再描画されたこれが見落とされる

最後の1つが肝心です。propsが1つも変わっていなくても、親が描き直されれば子も描き直されます。

まず、遅さを体験してみる

実際に測ってみます。ことりずかんに「観察ログ20万件から、鳥ごとの目撃数を集計する」という、業務にありがちな重い処理を足しました。

function countSightings(birds) {
  return birds.map((bird) => ({
    name: bird.name,
    count: bigLog
      .filter((row) => row.birdId === bird.id)
      .sort((a, b) => a.seenAt - b.seenAt).length,
  }))
}

function BirdListPage() {
  const { birds } = useBirds()
  const [keyword, setKeyword] = useState('')

  const sightings = countSightings(birds)   // ← 描画のたびに走る

}

検索ボックスに1文字打つたびにsetKeywordが走り、BirdListPageが描き直され、この集計も毎回まるごと走ります。手元で計測した結果がこれです。

状態1文字打つたびの集計時間
そのまま約28ms
useMemoで包む0ms

28msというのは、1秒間に60回の描画(1回16.6ms)に間に合わない大きさです。打鍵にワンテンポ遅れて文字が出る、あの感じになります。

useMemo——計算結果を覚えておく

useMemoは「依存配列の中身が変わらないかぎり、前回の結果を使い回す」道具です。

import { useMemo } from 'react'

const sightings = useMemo(() => countSightings(birds), [birds])

birdsが変わったときだけ計算し直し、検索語を打っただけなら計算せずに前回の結果を返します。上の表の0msはこれです。

書き方はuseEffectとよく似ていますが、役割が違います

いつ動く何のため
useEffect描画のあとReactの外とやりとりする(通信・タイマー)
useMemo描画の最中計算結果を覚えて、無駄な計算を避ける

memo——propsが同じなら子を描き直さない

もう1つの無駄は「親が描き直されたから子も描き直される」ほうです。実際に測ってみます。

星ボタンを1つ押したとき、再描画されたカードの枚数です。

状態再描画されたカード
そのまま8枚(全部)
memoだけ付ける8枚(変わらない!)
memouseCallback1枚(押したカードだけ)

memoはコンポーネントを包んで、propsが前回と同じなら再描画を飛ばす道具です。

import { memo } from 'react'

function BirdCard({ bird, isFavorite, onToggleFavorite }) { … }

export default memo(BirdCard)

ところが、これだけでは1枚も減りませんでした

memoが効かない理由——毎回「新しい関数」を渡している

memoは、前回のpropsと今回のpropsを===で比べます。ここに落とし穴があります。

function toggleFavorite(id) { … }   // 描画のたびに“新しい関数”が生まれる

中身が同じでも、関数は毎回別のオブジェクトです。だからmemoの判定は毎回「propsが変わった」になり、素通りしてしまいます。オブジェクトや配列をpropsで渡している場合も同じです。

そこでuseCallback——「依存配列が変わらないかぎり、同じ関数を返し続ける」道具です。

import { useCallback } from 'react'

const toggleFavorite = useCallback((id) => {
  setFavoriteIds((current) =>
    current.includes(id)
      ? current.filter((favoriteId) => favoriteId !== id)
      : [...current, id],
  )
}, [])

依存配列が[]なのは、中で外の値を読んでいないからです(setFavoriteIdsの関数形式を使っているおかげでfavoriteIdsを読む必要がありません)。これで関数の同一性が保たれ、memoがやっと効いて1枚だけになりました。

やってはいけない最適化

ここまで読むと全部に付けたくなりますが、それが典型的な失敗です。

  • 全部の部品をmemoで包む——比較のコストだけ増えて、速くならない
  • 軽い計算にuseMemo——足し引きでマイナス
  • 測る前に最適化する——遅い場所は、たいてい予想と違うところにある

React公式も「まず測れ」の立場です。手順はこうします。

  1. 開発者ツールのProfiler(React DevToolsのタブ)で記録する
  2. どの部品に何ミリ秒かかっているかを見る
  3. いちばん重い1か所だけ直す
  4. もう一度測って、効いたか確かめる
Reactエラーの読み方と開発者ツールReact DevToolsの入れ方はこの回に。Componentsタブの隣がProfilerです

React Compilerが入るとどうなるか

React 19以降、React Compilerという仕組みが実用段階に入りつつあります。これはビルド時にコードを解析して、必要な記憶化を自動で入れてくれるものです。

入れると、この回で書いたmemouseMemouseCallbackの多くは手で書かなくてよくなります

とはいえ、いま学ぶ意味は消えていません。

  • 既存のプロジェクトには手書きのmemoが大量に残っている(読めないと困る)
  • 導入は任意で、入っていない現場もまだ多い
  • なぜ必要だったのかを知らないまま自動化だけ使うと、効かないときに手が出ない

公式ドキュメントも見てみる

ja.react.dev外部サイトuseMemo – React公式のuseMemoリファレンス。「使用法」の最初の節に、どれくらい重ければ使う価値があるかの目安(1ms以上)まで書かれていますja.react.dev外部サイトmemo – Reactmemoのリファレンス。「propsが変わっていないのに再レンダーされる」というトラブルシューティングの節が、この回の実測と同じ話です

このレッスンのまとめ

  1. 再描画のきっかけはstate・props・Contextの値・親の再描画の4つ
  2. 再描画そのものは遅くない。目的は回数を減らすことではなく、操作に遅れないこと
  3. useMemoは重い計算の結果を覚える(実測28ms → 0ms)
  4. memoはpropsが同じなら子を描き直さない。ただしそれだけでは効かない
  5. 効かない理由は毎回新しい関数・オブジェクトを渡しているから → useCallbackとセット
  6. 軽い処理に付けると逆効果。測ってから、重い1か所だけ直す
  7. 開発中はStrictModeで2回描画される。正確な数字は本番ビルド+CPUスロットリング
  8. React Compilerが入ると手書きの記憶化は減るが、読めることは今も必要

次は、増えすぎたuseStateを整理するuseReducerです。

やってみよう:重い処理を作って、測って、直す

自分の手で「遅くしてから速くする」と、この回の内容が体に入ります。

  1. BirdListPage.jsxに、上のcountSightingsと20万件のbigLogを足す
  2. 集計の前後をperformance.now()ではさみ、console.logで時間を出す
  3. 検索ボックスに数文字打って、1文字ごとに20〜30msかかるのを確認する
  4. useMemo(() => countSightings(birds), [birds])に変えて、0msになるのを確認する
  5. BirdCardmemo()で包み、星を押したときのカードの再描画枚数をconsole.logで数える
  6. まだ8枚のままなのを確認したら、useFavoritestoggleFavoriteuseCallbackで包んで、1枚になるのを確認する
const bigLog = Array.from({ length: 200000 }, (_, i) => ({
  id: i,
  birdId: (i % 8) + 1,
  seenAt: (i * 7919) % 1000000,
}))

// 計測つきで呼ぶ
const t0 = performance.now()
const sightings = useMemo(() => countSightings(birds), [birds])
console.log('calc-ms', (performance.now() - t0).toFixed(1))

確認できたら、足した重い処理は消してかまいません(本来この図鑑には要らない処理です)。「測ってから直す」という順番だけ持ち帰ってください。

よくある質問

Reactはいつ再描画されますか?
そのコンポーネントのstateが変わったとき、propsが変わったとき、読んでいるContextの値が変わったとき、そして親が再描画されたときです。最後のひとつが見落とされがちで、propsが同じでも親が描き直されれば子も描き直されます。
memoを付けたのに再描画が減りません
propsに毎回新しい関数やオブジェクトを渡している可能性が高いです。中身が同じでも別物として比較されるため、memoの判定は毎回「変わった」になります。関数はuseCallback、オブジェクトや配列はuseMemoで包むと止まります。
useMemoは全部の計算に付けるべきですか?
いいえ。useMemo自体にも記憶と比較のコストがあり、軽い計算に付けると足し引きでかえって遅くなります。実際に重い処理だけに使ってください。
React Compilerを入れれば手動の最適化は不要になりますか?
多くの場面で不要になります。コンパイラが必要な記憶化を自動で入れるためです。ただし導入は任意で、既存プロジェクトではまだ手書きのmemoが残っているので、読めるようにしておく必要があります。