React のコンポーネントは、状態(state)や props が変わるたびに関数がまるごと再実行され、画面が作り直されます(再レンダリング)。ふだんは気にする必要はありませんが、「毎回重い計算が走ってしまう」「毎回新しい関数が作られて子コンポーネントまで無駄に再レンダリングされる」といった場面では、処理をムダに繰り返さない工夫が役立ちます。それが useMemo と useCallback によるメモ化です。この記事では、2つのフックの違いと基本的な使い方、依存配列の考え方、React.memo との組み合わせ、そして「そもそも使うべきか」の見極めまでを、初心者向けに解説します。
目次
メモ化とは何か
「メモ化」とは、一度計算した結果を覚えておき、条件が変わらない限りその結果を使い回すことです。再レンダリングのたびに同じ計算を繰り返すのはムダなので、「前回と同じ入力なら前回の結果をそのまま返す」ようにします。React では、値をメモ化するのが useMemo、関数そのものをメモ化するのが useCallback です。まず2つの役割を整理しておきましょう。
| フック | メモ化するもの |
|---|---|
useMemo | 計算の結果(値)。依存が同じなら再計算しない |
useCallback | 関数そのもの。依存が同じなら同じ関数を返す |
useMemo で重い計算をメモ化する
useMemo は「計算する関数」と「依存配列」の2つを渡します。依存配列に入れた値が変わったときだけ計算をやり直し、変わっていなければ前回の結果をそのまま返します。次の例では、リストの絞り込みという少し重い処理を useMemo で包んでいます。
import { useMemo, useState } from 'react';
function UserList({ users }: { users: string[] }) {
const [keyword, setKeyword] = useState('');
const [count, setCount] = useState(0);
// keyword か users が変わったときだけ絞り込みを再計算する
const filtered = useMemo(() => {
console.log('絞り込みを実行');
return users.filter((u) => u.includes(keyword));
}, [users, keyword]);
return (
<div>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<button onClick={() => setCount(count + 1)}>count: {count}</button>
<ul>{filtered.map((u) => <li key={u}>{u}</li>)}</ul>
</div>
);
}
この例では、ボタンで count を増やしても再レンダリングは起きますが、依存配列 [users, keyword] は変わっていないため絞り込みは実行されず、前回の filtered がそのまま使われます。もし useMemo を使わずに直接 users.filter(...) と書いていたら、count を変えるたびに絞り込みが毎回走ってしまいます。計算コストが大きい処理ほど、この差が効いてきます。
useCallback で関数をメモ化する
コンポーネントの中で const handleClick = () => {...} のように関数を定義すると、再レンダリングのたびに新しい関数オブジェクトが作られます。関数の中身が同じでも、React から見れば「毎回違う関数」です。これがなぜ問題になるかというと、その関数を子コンポーネントに props で渡している場合、props が毎回変わったと判断され、子まで再レンダリングされてしまうからです。useCallback は、依存配列が変わらない限り同じ関数を使い回すことでこれを防ぎます。
import { useCallback, useState } from 'react';
function Parent() {
const [count, setCount] = useState(0);
// 依存配列が空なので、この関数は最初の1回だけ作られ使い回される
const handleClick = useCallback(() => {
console.log('ボタンが押されました');
}, []);
return (
<div>
<p>count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<Child onClick={handleClick} />
</div>
);
}
useCallback(fn, []) と依存配列を空にすると、handleClick は最初のレンダリングで一度だけ作られ、以降は同じ関数が返されます。ちなみに useCallback(fn, deps) は useMemo(() => fn, deps) とほぼ同じで、「関数を返すことに特化した useMemo」だと考えると理解しやすいでしょう。ただし、これだけでは子の再レンダリングは止まりません。次に見る React.memo と組み合わせて初めて効果が出ます。
React.memo と組み合わせて子の再レンダリングを防ぐ
React.memo は、コンポーネントを包むと「props が前回と同じなら再レンダリングをスキップする」ようにしてくれる関数です。useCallback でメモ化した関数を、React.memo で包んだ子に渡すことで、親が再レンダリングされても子はそのまま、という状態を作れます。
import { memo } from 'react';
// props が変わらなければ再レンダリングしない
const Child = memo(function Child({ onClick }: { onClick: () => void }) {
console.log('Child を描画');
return <button onClick={onClick}>子ボタン</button>;
});
export default Child;
memo で包んだ Child は、受け取る onClick が前回と同じ関数であればレンダリングをスキップします。親の handleClick を useCallback でメモ化しているからこそ、この「同じ関数」という条件が満たされます。もし親で useCallback を使わず毎回新しい関数を渡していたら、React.memo で包んでいても props が変わったと判断され、子は毎回再レンダリングされてしまいます。この2つはセットで効果を発揮する、と覚えておきましょう。
依存配列の指定に注意する
useMemo・useCallback のどちらも、第2引数の依存配列が要になります。ここには「計算や関数の中で使っている、外側の値」をすべて入れるのが原則です。入れ忘れると、値が変わっても古い結果・古い関数が使われ続け、表示が更新されない不具合につながります。
// 悪い例: taxRate を使っているのに依存配列に入れていない
const total = useMemo(() => price * taxRate, [price]);
// 良い例: 使っている値をすべて依存配列に入れる
const totalFixed = useMemo(() => price * taxRate, [price, taxRate]);
上の悪い例では taxRate が変わっても total が再計算されず、古い税率のままになります。依存配列は自分で管理するのが難しいため、eslint-plugin-react-hooks の exhaustive-deps ルールを有効にしておくと、入れ忘れを警告してくれます。基本は「中で使った値はすべて入れる」と考え、警告に従うのが安全です。
なんでもメモ化してよいわけではない
useMemo や useCallback 自体にも、依存配列を比較したり前回の値を保持したりする小さなコストがあります。軽い計算や、子に渡していない関数にまで付けると、かえってコードが読みにくくなるだけで効果はありません。使いどころは、本当に重い計算を useMemo で包む、React.memo した子に渡す関数を useCallback で包む、といった「効果がはっきりある場面」に絞るのが基本です。まずは素直に書き、パフォーマンスの問題が実際に出てから最適化する、という順番で考えるとよいでしょう。
まとめ
useMemo は計算結果(値)を、useCallback は関数そのものをメモ化し、依存配列が変わらない限り前回のものを使い回します。useMemo は再レンダリングのたびに走る重い計算を防ぐのに、useCallback は React.memo した子コンポーネントへ渡す関数を安定させ、無駄な再レンダリングを止めるのに役立ちます。どちらも依存配列の指定が肝心で、使う値の入れ忘れは不具合の原因になります。一方で、あらゆる処理をメモ化すればよいわけではなく、効果がはっきりある場面に絞って使うのが、読みやすさと速さを両立するコツです。