ここまでは「動くものを作る」話でした。この回からは仕事で使うための話に入ります。まずは、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枚(変わらない!) |
memo + useCallback | 1枚(押したカードだけ) |
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公式も「まず測れ」の立場です。手順はこうします。
- 開発者ツールのProfiler(React DevToolsのタブ)で記録する
- どの部品に何ミリ秒かかっているかを見る
- いちばん重い1か所だけ直す
- もう一度測って、効いたか確かめる
React Compilerが入るとどうなるか
React 19以降、React Compilerという仕組みが実用段階に入りつつあります。これはビルド時にコードを解析して、必要な記憶化を自動で入れてくれるものです。
入れると、この回で書いたmemo・useMemo・useCallbackの多くは手で書かなくてよくなります。
とはいえ、いま学ぶ意味は消えていません。
- 既存のプロジェクトには手書きの
memoが大量に残っている(読めないと困る) - 導入は任意で、入っていない現場もまだ多い
- なぜ必要だったのかを知らないまま自動化だけ使うと、効かないときに手が出ない
公式ドキュメントも見てみる
ja.react.dev外部サイトuseMemo – React公式のuseMemoリファレンス。「使用法」の最初の節に、どれくらい重ければ使う価値があるかの目安(1ms以上)まで書かれています
ja.react.dev外部サイトmemo – Reactmemoのリファレンス。「propsが変わっていないのに再レンダーされる」というトラブルシューティングの節が、この回の実測と同じ話ですこのレッスンのまとめ
- 再描画のきっかけはstate・props・Contextの値・親の再描画の4つ
- 再描画そのものは遅くない。目的は回数を減らすことではなく、操作に遅れないこと
useMemoは重い計算の結果を覚える(実測28ms → 0ms)memoはpropsが同じなら子を描き直さない。ただしそれだけでは効かない- 効かない理由は毎回新しい関数・オブジェクトを渡しているから →
useCallbackとセット - 軽い処理に付けると逆効果。測ってから、重い1か所だけ直す
- 開発中はStrictModeで2回描画される。正確な数字は本番ビルド+CPUスロットリングで
- React Compilerが入ると手書きの記憶化は減るが、読めることは今も必要
次は、増えすぎたuseStateを整理するuseReducerです。