React hooksでメモ化を説明してみる

React Hooksは、ほとんどすべての面で私たちの生活を豊かにしてくれます。しかし、パフォーマンスが問題になってくると、少し厄介なことになります。Hooksを使って超高速なアプリケーションを書くことはできますが、その前に注意すべきことがあります。

 

メモ化するべきか

 

Reactはほとんどのユースケースで十分な速度を発揮します。もしあなたのアプリケーションが十分に速く、パフォーマンスに問題がないのであれば、この記事はあなたのためのものではありません。

想像上のパフォーマンス問題を解決するのは現実的なことなので、最適化を始める前に、React Profilerに精通していることを確認してください。

レンダリングに時間がかかるシナリオを確認した場合、メモ化はおそらく最善の策です。

React.memoはパフォーマンス最適化ツールであり、高次のコンポーネントです。

これはReact.PureComponentに似ていますが、クラスではなく関数コンポーネントのためのものです。関数コンポーネントが同じプロップを与えられて同じ結果をレンダリングする場合、Reactはmemoizeを行い、コンポーネントのレンダリングをスキップして、最後にレンダリングされた結果を再利用します。

デフォルトでは、propsオブジェクト内の複雑なオブジェクトを浅く比較するだけです。比較をコントロールしたい場合は、カスタムの比較関数を第2引数に指定することもできます。

メモ化をしない場合

 

メモ化を使用しない例を考え、それがなぜパフォーマンスの問題を引き起こすかを考えてみましょう。

 

function List({ items }) {
log('renderList');
return items.map((item, key) => (
<div key="{key}">item: {item.text}</div>
));
}export default function App() {
log('renderApp'); const [count, setCount] = useState(0);
const [items, setItems] = useState(getInitialItems(10)); return (
<div>
<h1>{count}</h1>
<button onclick="{()"> setCount(count + 1)}>
inc
</button>
    <list items="{items}"></list>

</div>
);
}

inc がクリックされるたびに、List には何も変更がないにもかかわらず、 renderApp と renderList の両方がログに記録されます。

ツリーのサイズが大きくなると、パフォーマンスのボトルネックになりやすくなります。レンダリングの回数を減らす必要があります。

 

簡単なメモ化

 

const List = React.memo(({ items }) => {
log('renderList');
return items.map((item, key) => (
<div key="{key}">item: {item.text}</div>
));
});export default function App() {
log('renderApp'); const [count, setCount] = useState(0);
const [items, setItems] = useState(getInitialItems(10)); return (
<div>
<h1>{count}</h1>
<button onclick="{()"> setCount(count + 1)}>
inc
</button>
    <list items="{items}"></list>

</div>
);
}

この例では、memoization が適切に機能し、レンダリング回数が減ります。

マウント時には renderApp と renderList がログに記録されますが、inc がクリックされると renderApp のみがログに記録されます。

メモライゼーションとコールバック

 

少し修正して、すべてのListアイテムにincボタンを追加してみましょう。

メモライズされたコンポーネントにコールバックを渡すと、微妙なバグが発生することがあるので注意が必要です。

function App() {
log('renderApp');

const [count, setCount] = useState(0);
const [items, setItems] = useState(getInitialItems(10));

return (
<div>
<div display:="" flex="">
<h1>{count}</h1>
<button onclick="{()"> setCount(count + 1)}>
inc
</button>

</div>
    <list items="{items}" inc="{()"> setCount(count + 1)}
/> </list>

</div>
);
}

この例では、memoizationが失敗しています。インライン・ラムダを使用しているため、レンダリングごとに新しい参照が作成され、React.memoが役に立たなくなっています。コンポーネントをmemoizeする前に、関数自体をmemoizeする方法が必要です。

 

useCallback

 

幸運なことに、Reactにはそのための2つの組み込みフックがあります:useMemoとuseCallbackです。

 

useMemoは高価な計算に便利で、useCallbackは最適化された子コンポーネントに必要なコールバックを渡すのに便利です。

 

function App() {
log('renderApp');

const [count, setCount] = useState(0);
const [items, setItems] = useState(getInitialItems(10));

const inc = useCallback(() => setCount(count + 1));

return (
<div>
<div display:="" flex="">
<h1>{count}</h1>
<button onclick="{inc}">inc</button>

</div>
    <list items="{items}" inc="{inc}"></list>

</div>
);
}

 

この例では、メモ化が再び失敗しています。renderListは、incが押されるたびに呼び出されます。

useCallback のデフォルトの動作は、新しい関数インスタンスが渡されるたびに新しい値を計算することです。

インライン・ラムダは、レンダリングのたびに新しいインスタンスを作成するので、デフォルト設定の useCallback はここでは役に立ちません。

 

入力付き useCallback

 

const inc = useCallback(() => setCount(count + 1), [count]);

 

useCallback は 2 番目の引数として入力の配列を取り、これらの入力が変更された場合にのみ useCallback は新しい値を返します。

この例では、useCallback は、カウントが変更されるたびに新しい参照を返します。

 

カウントは各レンダリング中に変更されるので、useCallback は各レンダリング中に新しい値を返します。このコードは、同様にメモライズしません。

 

空の配列の入力を持つ useCallback

 

const inc = useCallback(() => setCount(count + 1), []);

 

useCallback は、入力として空の配列を取ることができ、内側のラムダを一度だけ呼び出して、将来の呼び出しのために参照を記憶します。このコードはメモライズを行い、いずれかのボタンをクリックすると、1つの renderApp が呼び出され、メインの inc ボタンは正しく動作しますが、内側の inc ボタンは正しく動作しなくなります。

カウンタは0から1へと増加し、その後停止します。ラムダは一度だけ作成されますが、複数回呼び出されます。ラムダが作成された時点ではカウンタは0なので、以下のコードと全く同じ動作をします。

 

const inc = useCallback(() => setCount(1), []);

 

この問題の根本的な原因は、状態の読み取りと書き込みを同時に行おうとしていることです。この目的のために設計されたAPIが必要です。幸いなことに、Reactはこの問題を解決するために2つの方法を提供しています。

 

機能的なアップデートを行ったuseState

 

const inc = useCallback(() => setCount(c => c + 1), []);

 

useStateが返すセッターは、引数として関数を取ることができ、そこで与えられた状態の以前の値を読み取ることができます。この例では、memoizationはバグなしで正しく動作します。

 

useReducer

 

const [count, dispatch] = useReducer(c => c + 1, 0);

 

useReducerのメモライゼーションは、この場合、useStateと同じように機能します。ディスパッチは、レンダー間で同じ参照を持つことが保証されているため、useCallback は必要なく、コードはメモライゼーション関連のバグによるエラーを起こしにくくなります。

useReducer と useState の比較

useReducerは、複数のサブ値を含むステートオブジェクトの管理や、次のステートが前のステートに依存する場合に適しています。useReducerを使用する一般的なパターンは、大きなコンポーネントツリーでコールバックを明示的に渡さないようにするためにuseContextと一緒に使用します。

私がお勧めする経験則では、コンポーネントから出ないデータについてはほとんどuseStateを使用しますが、親と子の間で自明ではない双方向のデータ交換が必要な場合は、useReducerの方が良いでしょう。

要約すると、React.memoとuseReducerは親友であり、React.memoとuseStateは時々喧嘩して問題を起こす兄弟であり、useCallbackは常に気をつけなければならない隣人である。

 

参考

藤沢瞭介(Ryosuke Hujisawa)
  • りょすけと申します。18歳からプログラミングをはじめ、今はフロントエンドでReactを書いたり、AIの勉強を頑張っています。off.tokyoでは、ハイテクやガジェット、それからプログラミングに関する情報まで、エンジニアに役立つ情報を日々発信しています!

未整理記事

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です