この記事はAll About Group(株式会社オールアバウト)Advent Calendar 2023の21日目の投稿です。
はじめに
こんにちは、株式会社オールアバウトの@hide2020です。
Reactのパフォーマンス最適化に対する理解を深めるため、memo・useCallback・useMemoについて、勉強しつつまとめてみました。
この記事を読むことで、以下の悩みや疑問を解消できるでしょう。
- memo・useMemo・useCallbackの使い方が分からない..
- memo・useMemo・useCallbackの使い所が分からない..
- memo・useMemo・useCallbackの使い分けが分からない..
- Reactのパフォーマンス最適化って何をすればいいの?
少しでも 「memo・useMemo・useCallbackがよく分からない。。」 と感じている方の参考になれば幸いです。
Reactの再描画のタイミングについて
Reactのコンポーネントは、以下のタイミングで再描画(再レンダリング)が発生します。
- propsや内部状態が更新されたとき
- コンポーネント内で参照しているContextの値が更新されたとき
- 親コンポーネントが再描画されたとき
デフォルトだと、上位のコンポーネントで再描画が発生すると、それ以下の全てのコンポーネントでも再描画が発生してしまいます。
memoについて
親コンポーネントが再描画される度に発生する子コンポーネントの再描画の伝播を止めるために使用するのが、memoです。
import { memo } from 'react';
const SomeComponent = memo(function SomeComponent(props) {
// ...
});
memoは、コンポーネントをラップする形で使用し、メモ化が有効になった新たなコンポーネントを返します。
メモ化コンポーネントを利用することで、親コンポーネントが再描画したとしても、propsの値が変化しない限り、子コンポーネントの再描画をスキップすることができます。
例えば、以下のコードの場合、isMultipleOfFive
の値が変わっていない場合も、親コンポーネントの再描画が発生する度に子コンポーネントも再描画されてしまいます。
import { useState } from "react";
type Props = {
isMultipleOfFive: boolean;
};
const Child = ({ isMultipleOfFive }: Props) => {
console.log(`Childが再描画されました!, isMultipleOfFive=${isMultipleOfFive}`);
return (
<span>{isMultipleOfFive ? "5の倍数です" : "5の倍数ではありません"}</span>
);
};
Child.displayName = "Child";
export const ReactMemoSample = (): JSX.Element => {
const [count, setCount] = useState(1);
const isMultipleOfFive = count % 5 === 0;
console.log(`Parentが再描画されました, count=${count}`);
return (
<div>
<button type="button" onClick={() => setCount((c) => c + 1)}>
+1する
</button>
<p>{`現在のカウント: ${count}`}</p>
<p>
<Child isMultipleOfFive={isMultipleOfFive} />
</p>
</div>
);
};
しかし、isMultipleOfFive
の値が変わらない限り子コンポーネントの表示内容は変わらないため、本来はisMultipleOfFive
の値が変化したときのみ子コンポーネントは再描画されればいいはずです。
そこでmemoを利用します。
memoを利用することで、isMultipleOfFive
の値が変化したときのみ、子コンポーネントを再描画させることができます。
import { memo, useState } from "react";
type Props = {
isMultipleOfFive: boolean;
};
const Child = memo(({ isMultipleOfFive }: Props) => {
console.log(`Childが再描画されました!, isMultipleOfFive=${isMultipleOfFive}`);
return (
<span>{isMultipleOfFive ? "5の倍数です" : "5の倍数ではありません"}</span>
);
});
Child.displayName = "Child";
export const ReactMemoSample = (): JSX.Element => {
const [count, setCount] = useState(1);
const isMultipleOfFive = count % 5 === 0;
console.log(`Parentが再描画されました, count=${count}`);
return (
<div>
<button type="button" onClick={() => setCount((c) => c + 1)}>
+1する
</button>
<p>{`現在のカウント: ${count}`}</p>
<p>
<Child isMultipleOfFive={isMultipleOfFive} />
</p>
</div>
);
};
ただし、memoの場合、propsに関数やオブジェクトが渡された場合は子コンポーネントの再描画を抑制できません。
Reactは新しいpropsのそれぞれの値が、以前のpropsと"参照ベースで"等価であるかどうかを比較するからです。親コンポーネントが再描画のたびに新しいオブジェクトや配列や関数を作成している場合、個々の要素が同じであっても、変更があったとみなされてしまいます。
参考: memo トラブルシューティング
例えば以下のコードの場合、子コンポーネントのpropsに関数を渡しているため、子コンポーネントは「+1する」ボタンを押す度に毎回再描画されてしまいます。
import { memo, useState } from "react";
type Props = {
isMultipleOfFive: boolean;
onClick: () => void;
};
const Child = memo(({ isMultipleOfFive, onClick }: Props) => {
console.log(`Childが再描画されました!, isMultipleOfFive=${isMultipleOfFive}`);
return (
<>
<button type="button" onClick={onClick}>
Childのボタン
</button>
<span>{isMultipleOfFive ? "5の倍数です" : "5の倍数ではありません"}</span>
</>
);
});
Child.displayName = "Child";
export const ReactMemoSample = (): JSX.Element => {
const [count, setCount] = useState(1);
const isMultipleOfFive = count % 5 === 0;
const onClick = () => {
console.log("Childがクリックされました!");
};
console.log(`Parentが再描画されました, count=${count}`);
return (
<div>
<button type="button" onClick={() => setCount((c) => c + 1)}>
+1する
</button>
<p>{`現在のカウント: ${count}`}</p>
<p>
<Child isMultipleOfFive={isMultipleOfFive} onClick={onClick} />
</p>
</div>
);
};
そこで使用するのが、Reactの組み込みフックである、useCallbackやuseMemoです。
useCallbackについて
子コンポーネントに渡す関数ををuseCallbackでラップした場合、親コンポーネントが再描画された場合に新しい関数が作成されるの防げるため、子コンポーネントが再描画されるのを抑制することができます。
例えば、先ほどのコードで、onClickをuseCallbackでラップすると、「+1する」ボタンを押したとしても、子コンポーネントが再描画されることはなくなります。
import { memo, useCallback, useState } from "react";
type Props = {
isMultipleOfFive: boolean;
onClick: () => void;
};
const Child = memo(({ isMultipleOfFive, onClick }: Props) => {
console.log(`Childが再描画されました!, isMultipleOfFive=${isMultipleOfFive}`);
return (
<>
<button type="button" onClick={onClick}>
Childのボタン
</button>
<span>{isMultipleOfFive ? "5の倍数です" : "5の倍数ではありません"}</span>
</>
);
});
Child.displayName = "Child";
export const ReactMemoSample = (): JSX.Element => {
const [count, setCount] = useState(1);
const isMultipleOfFive = count % 5 === 0;
const onClick = useCallback(() => {
console.log("Childがクリックされました!");
}, []);
console.log(`Parentが再描画されました, count=${count}`);
return (
<div>
<button type="button" onClick={() => setCount((c) => c + 1)}>
+1する
</button>
<p>{`現在のカウント: ${count}`}</p>
<p>
<Child isMultipleOfFive={isMultipleOfFive} onClick={onClick} />
</p>
</div>
);
};
useCallbackは、第一引数には関数を、第二引数は依存配列を指定します。
関数の再描画が行われるときに、useCallbackは依存配列の中の値を比較します。
依存配列の中の値が前の描画時と同じ場合は、useCallbackはメモ化された関数を返し、依存配列の中で異なるものがあれば、現在の第一引数の関数をメモに保存します。
今回は依存配列が空であるため、useCallbackは初期描画時に生成された関数を常に返します。
そのため、Childに渡される関数も参照ベースで毎回同じになるため、親の再描画によるChildの再描画は発生しません。
useMemoについて
useCallbackのは「関数」のメモ化でしたが、useMemoは「値(関数の呼び出し結果)」をメモ化します。
例えば、以下のコードの場合、入力欄に文字を入力する度に再描画が発生するため、reduce関数の処理も毎回呼ばれてしまいます。
import { css } from "@emotion/react";
import React, { useState } from "react";
const input = css`
border: 1px solid #ccc;
margin-right: 8px;
`;
export const UseMemoSample = (): JSX.Element => {
const [text, setText] = useState("");
const [textItems, setTextItems] = useState<string[]>([]);
const onChangeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const onClickButton = () => {
setTextItems((prevItems) => {
return [...prevItems, text];
});
setText("");
};
const numberOfCharacters = textItems.reduce((sub, item) => {
console.log("useMemoを使わないパターン");
return sub + item.length;
}, 0);
return (
<div>
<div>
<input value={text} css={input} onChange={onChangeInput} />
<button type="button" onClick={onClickButton}>
追加する
</button>
</div>
<div>
{textItems.map((item) => (
<p key={item}>{item}</p>
))}
</div>
<div>
<p>文字列合計数: {numberOfCharacters}</p>
</div>
</div>
);
};
今回の場合、reduceの処理は「追加する」ボタンを押してitemsにデータが追加されたときのみ動けば良いので、これは明らかに無駄な計算処理であり、itemsが増えれば増えるほどパフォーマンスが悪化してしまいます。
そこで利用するのが、useMemoです。これを利用することで、コンポーネントがレンダリングする度に無駄にreduceの処理が走るのを防ぐことができます。
import { css } from "@emotion/react";
import React, { useState, useMemo } from "react";
const input = css`
border: 1px solid #ccc;
margin-right: 8px;
`;
export const UseMemoSample = (): JSX.Element => {
const [text, setText] = useState("");
const [textItems, setTextItems] = useState<string[]>([]);
const onChangeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const onClickButton = () => {
setTextItems((prevItems) => {
return [...prevItems, text];
});
setText("");
};
const numberOfCharacters = useMemo(() => {
return textItems.reduce((sub, item) => {
console.log("useMemoを使ったパターン");
return sub + item.length;
}, 0);
}, [textItems]);
return (
<div>
<div>
<input value={text} css={input} onChange={onChangeInput} />
<button type="button" onClick={onClickButton}>
追加する
</button>
</div>
<div>
{textItems.map((item) => (
<p key={item}>{item}</p>
))}
</div>
<div>
<p>文字列合計数: {numberOfCharacters}</p>
</div>
</div>
);
};
useMemoは、第一引数にはキャッシュしたい値を計算する関数を、第二引数には依存する値を指定します。
const cachedValue = useMemo(calculateValue, dependencies)
そして、第二引数に指定した値が変化した場合のみ関数の処理が走り、値の再計算が行われます。
依存配列が変化しない場合はメモ化しておいた値を常に返します。
これにより、無駄に計算やレンダリングの処理が走ってパフォーマンスが低下するのを防ぐことができます。
また、関数の計算結果を子コンポーネントに渡す場合、子コンポーネントに渡す値を生成する関数をuseMemoでラップしておくことで、親コンポーネントの再描画に伴う子コンポーネントの再描画をスキップすることもできます。(この辺りの挙動はuseCallbackと同じです)
memo・useCallback・useMemoの使い所
memoは親コンポーネントが再描画した際に子コンポーネントを再描画させたくない場合に使用し、useCallbackは関数を、useMemoは関数の結果をmemo化コンポーネントに渡した際にも子コンポーネントの再描画が発生しないようにするために使用します。
useMemoは、単純にレンダリングの度に重い計算処理が走らないようにする目的でも使用できます。
- memoの使い所
- 子コンポーネントの描画コストが高いため、親コンポーネントの再描画に伴う子コンポーネントの再描画をさせたくない場合
- より具体的に言うと、コンポーネントが全く同一の props で頻繁に再描画され、しかもそのロジックが高コストである場合
- useCallbackの使い所
- memo化したコンポーネントの引数に関数を渡す場合、その関数をuseCallbackでラップすることで子コンポーネントの無駄な再描画を防げる
- カスタムフックで関数を返す場合、関数をuseCallbackでラップすることで、カスタムフックの汎用性を向上させることができる
- useMemoの使い所
- memo化したコンポーネントの引数に関数の計算結果を渡す場合、その関数をuseMemoでラップすることで、無駄に値が変化して子コンポーネントが再描画されるのを防げる
- コンポーネント内に複雑で高コストな計算処理がある場合、計算処理をuseMemoでラップすることで、コンポーネントが再描画するたびに複雑な計算処理が走るのを防ぐことができる
- 複雑な計算の例: 巨大な配列をフィルタリング・変換する処理、何千ものオブジェクトを作成したりループさせたりする処理...など
計算コストが高いかどうかを見分ける方法
基本的には、何千ものオブジェクトを作成したりループしたりしていない限り、おそらく高価ではないです。
以下のように対象の処理にかかる時間を計測することもできます。
console.time('filter array');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('filter array');
ログ時間がかなりの量(例えば 1ms 以上)になる場合、その計算をメモ化する意味があるかもしれません。
また、React Developer Toolsを使用することで、レンダリングに時間がかかっている処理や再レンダリングが行われたかどうかを視覚的に知ることもできます。
Chrome拡張をインストールするだけで簡単に導入でき、パフォーマンス最適化の目的以外にも、純粋にレンダリングされたコンポーネントを把握したい場合にも使えるのでおすすめです。
memo・useCallback・useMemoの使い分け
基本的には以下のように使い分ければいいと思います。
- 引数のpropsの値があまり変化せず、子コンポーネントの描画コストが高いため、親コンポーネントの再描画に伴う子コンポーネントの再描画をさせたくない場合
- memo関数を使う
- 引数のpropsで関数を渡す必要がり、子コンポーネントの描画コストが高いため、親コンポーネントの再描画に伴う子コンポーネントの再描画をさせたくない場合
- memoとuseCallbackを組み合わせて使う
- 引数のpropsで複雑な計算処理(関数)の結果の値を渡す必要がり、子コンポーネントの描画コストが高いため、親コンポーネントの再描画に伴う子コンポーネントの再描画をさせたくない場合
- memoとuseMemoを組み合わせて使う
- コンポーネントの再描画に伴い複雑な計算処理(関数の処理)が毎回走るのを防ぎたい場合
- useMemoを使う
memo・useMemo・useCallbackを使い倒すべきなのか?
基本的には、パフォーマンスが問題になる場合のみ(パフォーマンスの最適化としてのみ)memo・useMemo・useCallbackを使うべきです。
なぜなら、パフォーマンスに影響がほとんどないのにmemo・useMemo・useCallbackを使うというのは、無駄に実装の複雑度を上げる(コードの可読性を下げる)だけだからです。
実装の複雑度が上がれば、コンポーネントの理解が難しくなり、デバッグの速度も低下してしまいます。
この辺りのことは、公式のそれぞれの記事にも書かれてあります。ぜひ目を通してみてください。
ただ、実装の複雑度が上がると言っても、そこまで急激に上がるわけではなく、他に大きなデメリットも無い(処理のオーバーヘッドも基本的には無視できる程度である)ため、基本的にこれらの処理を使う方針に倒すのもありかもしれません。
参考: そこのお前! 余計なuseMemo1個に含まれるオーバーヘッドは余計なdiv 0.57個分だぜ!
実際にこれらのメモ化処理を使い倒す方針にしているプロジェクトも存在しているようです。
この辺りはチーム内でルールを決めて認識を統一しておくと良いでしょう。
私個人の意見としては、なるべく実装をシンプルに保つのが好みなのと、パフォーマンスチューニングの原則である、「推測するな、計測せよ」が染み付いているので、何でもかんでもメモ化するのには若干抵抗があります。
ただ、メモ化を積極的に使うことで設計や実装効率の面でメリットがあることも事実ではある(特にカスタムフックにおけるuseCallbackの使用など)ので、メリット・デメリットを天秤にかけた上で、プロジェクト毎にどのような方針にするかは判断していきたいと考えています。
おわりに
今回は、memo・useMemo・useCallbackの使い方と使い所と使い分けについてまとめました。
memo化の処理は、Reactでパフォーマンスの最適化をするなら、必ず知っておくべき内容です。
この記事が、Reactのパフォーマンス最適化に対する理解を深める上で、少しでも参考になっていれば幸いです。
最後まで読んでいただきありがとうございました!