はじめに
単純に「メモ化」と言っても、メモ化は1種類ではない。
この記事では、useMemo、useCallback、memo のそれぞれの役割と違いを整理する。
また、公式ドキュメントを読む中で自分自身がつまづいた部分も合わせて理解を残しておく。
先に結論
useMemo |
useCallback |
memo |
|
|---|---|---|---|
| 分類 | Hook | Hook | API |
| 対象 | 値・計算結果 | 関数 | コンポーネント |
| 主な働き | 計算結果・値を再利用する | 関数の参照を再利用する | 再レンダーをスキップできるようにする |
| 判断材料 | 依存値 | 依存値 | props |
| 主な用途 | 不要な計算を避ける・値の参照を安定させる | 関数の参照を安定させる | 不要な再レンダーを避ける |
1. Reactの値と依存関係を理解する
useMemo や useCallback を理解するうえで、まず「依存配列は何を意味しているのか」を整理しておきたい。
1-1. 依存配列は「値を渡すもの」ではない
例えば、
const result = useMemo(() => {
return calculate(items);
}, [items]);
と書く。
この [items] を見て、最初は「items を useMemo に渡している」と考えがちだった。
しかし、calculate の items はコンポーネントのスコープにある items を、その関数の中から参照している。
一方、依存配列の [items] は、その items が前回と比較して変わったかどうかを、useMemo の計算をもう一度実行するか判断するために使われる。
つまり、
-
calculate(items)のitems→ 計算に使用している値 -
[items]のitems→ 計算を再実行するか判断するための依存値
という違いがある。
慣れてしまえば当たり前であるが、同じ items という名前が登場していても、「関数の中で値を参照すること」と「依存配列に値を指定すること」は役割が違う。
1-2. reactive value(リアクティブ値)とは何か
Reactの依存配列を理解するときには、reactive value(リアクティブ値)という考え方も重要になる。
React公式では、コンポーネントのprops、state、またはコンポーネント内に直接宣言された変数・関数などをリアクティブな値として扱う。
例えば、
function ProductPage({ productId, referrer }) {
const handleSubmit = useCallback(() => {
post(`/product/${productId}/buy`, {
referrer,
});
}, [productId, referrer]);
}
この場合、productId と referrer はコンポーネントのpropsとして渡されているリアクティブな値なので、useCallback の依存配列に指定する。
ここで重要なのが、「関数の引数と、関数の外側から参照している値は別物」ということ。
例えば、
const handleSubmit = useCallback((orderDetails) => {
post(`/product/${productId}/buy`, {
orderDetails,
});
}, [productId]);
の場合、
productId
↓
コールバック関数の外側にあるリアクティブな値
orderDetails
↓
handleSubmitが呼び出されたときに渡される引数
となる。
productId はコールバック関数の外側にあるリアクティブな値を参照しているため、依存配列に含める必要がある。
一方、 orderDetails は handleSubmit の引数なので、useCallback の依存配列には入れない。
つまり、依存配列を考えるときは、
「この関数の中で使っている変数か?」ではなく、
「この関数の外側にあるリアクティブな値を参照しているか?」
という視点が重要になる。
1-3. オブジェクトの参照と Object.is
Reactのメモ化を理解するうえでは、値そのものだけでなく「参照」も重要になる。
例えば、
const a = { roomId: 1 };
const b = { roomId: 1 };
とすると、
Object.is(a, b); // false
になる。
中身は同じでも、別々のオブジェクトである。
一方、
const a = { roomId: 1 };
const b = a;
Object.is(a, b); // true
なら同じオブジェクトを参照している。
この「同じ中身か」ではなく「同じ参照か」という考え方が、値が変化しているかを判定するときに重要になってくる。
2. useMemo ― 値・計算結果をメモ化する
ここからは、3つのメモ化の方法について、それぞれの役割を整理していく。
useMemo は、計算結果などの値を再利用するHook 。
const result = useMemo(() => {
return expensiveCalculation(data);
}, [data]);
依存値である data が前回と同じであれば、前回の計算結果を再利用できる。
逆に data が変われば、計算をやり直す。
3. memo ― コンポーネントの再レンダーをスキップする
memo も「メモ」という名前がついているが、useMemo とは対象が違う。
const Child = memo(function Child({ data }) {
return <div>{data.name}</div>;
});
memo はコンポーネントを受け取り、propsが前回と変わっていなければ、そのコンポーネントの再レンダーをスキップできるようにする。
つまり、
useMemo
↓
値・計算結果を再利用
memo
↓
コンポーネントの再レンダーをスキップ
という違いがある。
3-1. propsはどう比較されるのか
memo のデフォルトの比較では、propsを比較するときに Object.is による比較が使われる。
そのため、オブジェクトや配列、関数などをpropsとして渡す場合には、参照が変わっているかどうかが重要になる。
例えば、
function Parent({ items }) {
const data = {
items,
};
return <Child data={data} />;
}
の場合、Parentが再レンダーするたびに新しい data オブジェクトが作られる。
Parentが再レンダー
↓
新しいdataオブジェクト
↓
参照が変わる
↓
memo「propsが変わった」と判断
↓
Childも再レンダー
このような場合に、
const data = useMemo(() => {
return { items };
}, [items]);
と useMemo を併用すると、items が変わらない限り data の参照を安定させられる。
3-2. memo を使ってもすべての再レンダーを防げるわけではない
memo は「そのコンポーネントを絶対に再レンダーさせない」というものではない。
例えば、コンポーネント自身のstateが更新された場合や、利用しているContextの値が変化した場合などには、propsが変わっていなくても再レンダーすることがある。
4. useCallback ― 関数をメモ化する
useCallback は、関数の参照を再利用するHook 。
const handleClick = useCallback(() => {
console.log("click");
}, []);
コンポーネントが再レンダーすると、通常は関数も新しく作られる。
Parentが再レンダー
↓
新しい関数が作られる
↓
関数の参照が変わる
useCallback を使うと、依存値が変わらない限り、同じ関数の参照を再利用できる。
4-1. useCallback と useMemo は同じ処理でも書ける
例えば、
const handleClick = useCallback(() => {
console.log("click");
}, []);
は、概念的には次のように useMemo を使って表現することもできる。
const handleClick = useMemo(() => {
return () => {
console.log("click");
};
}, []);
では、なぜ useCallback が存在するのか。
useCallback は、「関数をメモ化したい」 という意図を直接表現するためのHookだから。
useMemo(() => {
return () => {
// ...
};
}, []);
よりも、
useCallback(() => {
// ...
}, []);
のほうが、「この関数の参照を安定させたい」という意図が明確になり、記述も簡潔になる。
そのため、useCallback は、関数をメモ化するための専用のHook と考えると分かりやすい。
4-2. なぜ関数の参照を安定させるのか
useCallback 単体で使うことよりも、useMemo のときと同じように memo と組み合わせる場面が重要になる。
例えば、
const Child = memo(function Child({ onClick }) {
return <button onClick={onClick}>Click</button>;
});
としても、Parent側で、
function Parent() {
const handleClick = () => {
console.log("click");
};
return <Child onClick={handleClick} />;
}
とすると、Parentが再レンダーするたびに新しい関数が作られる。
Parentが再レンダー
↓
新しいhandleClick
↓
関数の参照が変わる
↓
memo「propsが変わった」と判断
↓
Childも再レンダー
そこで、
const handleClick = useCallback(() => {
console.log("click");
}, []);
とすると、依存値が変わらない限り関数の参照を安定させられる。
これによって memo によるprops比較で「変わっていない」と判断できるようになり、Childの再レンダーをスキップできる場合がある。
5. useMemo / useCallback と memo の関係
ここまで3つを個別に見てきたので、それぞれがどう組み合わさるのかを整理する。
例えば、
const Child = memo(function Child({ data, onClick }) {
// ...
});
function Parent({ items }) {
const data = useMemo(() => {
return process(items);
}, [items]);
const onClick = useCallback(() => {
// ...
}, []);
return <Child data={data} onClick={onClick} />;
}
この場合、
Parent
│
┌───────┴────────┐
↓ ↓
useMemo useCallback
↓ ↓
dataの参照 関数の参照
│ │
└───────┬────────┘
↓
Child
↑
memo
↓
propsを比較
↓
変わっていなければ
再レンダーをスキップ
となる。
つまり、
useMemoとuseCallbackが値や関数の参照を安定させ、それによってmemoがpropsの変更なしを検出しやすくなる
という関係。
必要な場面では組み合わせて使うことができる。
6. まとめ
ここまでの内容をもう一度整理する。
6-1. 3つの違い
useMemo |
useCallback |
memo |
|
|---|---|---|---|
| 分類 | Hook | Hook | API |
| 対象 | 値・計算結果 | 関数 | コンポーネント |
| 主な働き | 計算結果・値を再利用する | 関数の参照を再利用する | 再レンダーをスキップできるようにする |
| 判断材料 | 依存値 | 依存値 | props |
| 主な用途 | 不要な計算を避ける・値の参照を安定させる | 関数の参照を安定させる | 不要な再レンダーを避ける |
6-2. メモ化する前に考えること
そして、今回の学習を通してもう一つ重要だと感じたのが、「メモ化の方法を覚えること」と「メモ化が必要か判断すること」は別ということ。
useMemo や useCallback は、使えば使うほどパフォーマンスが良くなるものではない。
例えば、
- そもそもメモ化するほど重い処理なのか
- 関数の参照を安定させる必要があるのか
-
memoによって子コンポーネントの再レンダーをスキップする必要があるのか - 依存配列に必要な値が正しく含まれているか
- ロジックを書き換えることで、不要な依存を減らせないか
といったことを考える必要がある。
例えば、stateを更新するとき、
const handleAddTodo = useCallback((text) => {
const newTodo = { id: nextId++, text };
setTodos([...todos, newTodo]);
}, [todos]);
と書く代わりに、
const handleAddTodo = useCallback((text) => {
const newTodo = { id: nextId++, text };
setTodos(todos => [...todos, newTodo]);
}, []);
と書ける場合がある。
最初のコードでは、
todos
↓
コールバック関数の外側にあるstateを参照
↓
依存配列にtodosが必要
となっている。
一方、後のコードでは、
todos => [...]
↓
Updater Functionの引数として
現在のstateを受け取る
となっているため、コールバック関数の外側にある state todos を直接参照していない。
そのため、todos を useCallback の依存配列に入れる必要がなくなる。
ここで重要なのは、依存配列から値を削除することではなく、そもそもその値に依存しないロジックにできるかを考えることである。
また、そもそもメモ化そのものが必要ない場合もある。
例えば、非常に軽い計算に useMemo を追加したり、memo された子コンポーネントに渡していない関数をすべて useCallback でメモ化したりしても、必ずしも効果があるとは限らない。
そのため、「どうやってメモ化するか」より先に、「なぜメモ化するのか」 を考えることが重要になる。
メモ化は目的ではなく、パフォーマンス上の問題を解決するための手段である。
補足:React Compiler
React Compilerを利用している場合、コンポーネントや値のメモ化をコンパイラが自動的に最適化できる。
そのため、新しいコードでは、手動で useMemo、useCallback、memo を追加する必要性が以前より低くなっている。
ただし、useMemo や useCallback が完全になくなるわけではない。
特定の値を明示的にメモ化するなど、細かな制御が必要な場合には、引き続き利用できる。
おわりに
今回、useMemo、useCallback、memo を調べていく中で、「メモ化」という一つの言葉だけでは整理できないことが分かった。
そして、それぞれを理解するためには、単に使い方を覚えるだけではなく、
- 依存配列は何を意味しているのか
- リアクティブな値とは何か
- オブジェクトや関数の参照はどう比較されるのか
- そもそもメモ化が必要なのか
といった、より基本的な部分を理解する必要があった。