React.memo/useCallback/useMemo...知ってはいるけどいつ使えば良いかわからない
Reactを始めてまもない方やバックエンドとフロントを両方兼務している方にとって、レンダリング最適化やパフォーマンスチューニングは苦手意識を持つ方が多いと思います。
かくいう私も普段はPHPとTypeScriptをいったりきたりしつつ、
フロント業務ではNext/Reactを触り始めてはいるもののメモ化周りとチューニング方法は全く理解できていませんでした。
原因を振り返ってみると以下のようなものでした
「各フックそれぞれの説明において、その場では分かった気がしていた」
「console.logが埋め込まれた前提で再レンダリングの説明をされるだけでは応用が効かない
「問題を検証 → 分析 → 改善(ここでメモ化周りのhooksを使う)フローで進められていない」
「そもそもどこがネックなのか検証できるツールと利用方法を理解していない」
そんな反省を踏まえて自分用の備忘録的に今回の記事を書こうと思いました。
対象者は以下の方々を想定しています。
・React Developer Toolsをの基本的な使い方を知りたい方
・why-did-you-renderライブラリを利用した基本的な使い方を知りたい方
・Reactを触り始めたけれどReact.memo/useCallback/useMemoの使い所がわからない方
※私自身、React歴はかなり浅いのでご指摘あれば是非、具体例(コード)と編集リクエストを頂けると大変嬉しく思います。
進め方
本記事では以下の手順で解説していきます。
- 再レンダリングされる仕組みを再整理
- 検証ツールの使い方
- 簡単なケース問題
また利用しているコードは、
1.に関しては全てcodesandboxというブラウザ統合開発環境で確認できるようにしております。
2.以降に関してはVercel_Next.js ×Why did you renderをベースに構築しています。
動作確認されたい方は、コピペミス防止のため私のリポジトリをクローン頂ければ早いかと思います。各章毎にブランチを切っております。
1.再レンダリングされる仕組みを再整理
Reactにおいて再レンダリングが行われるタイミングは以下の2つです。
- stateに変化が起きた時
- 親コンポーネントが再レンダリングされた時
今後のベースとなる知識になるため、しっかり整理しておきましょう。
stateに変化が起きた時
import { useState } from "react";
export default function App() {
console.log("stateが更新されたため再レンダリング!");
const [text, setText] = useState("");
const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
return (
<>
<p>ここに文字を入力</p>
<input type="text" onChange={changeText} />
<p>入力された文字が反映されます</p>
<input type="text" value={text} />
</>
);
}
input内に文字を入力するとonChange
のsetText
によってstateが変更されます。
consoleには、stateが更新される度に出力されていることがわかります。
つまり、stateが更新される度に再レンダリングが行われています。
親コンポーネントが再レンダリングされた時
import { useState } from "react";
const Child = () => {
console.log("子が再レンダリング");
return (
<>
<p>子コンポーネントが表示されています</p>
</>
);
};
export default function Parent() {
console.log("親が再レンダリング!");
const [text, setText] = useState("");
const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
return (
<>
<p>親コンポーネントで文字を入力</p>
<input type="text" onChange={changeText} />
<Child />
</>
);
}
上記と同様ですが、input内に文字を入力するとonChange
のsetText
によってstateが変更されます。stateを定義しているのは親コンポーネントであるため親側では入力ごとにレンダリング=console.logが出力されています。
一方で、子コンポーネント内ではstateは利用されていないため、再レンダリングの対象にはならないのではと思われたかもしれません。
通常のレンダリングでは、Reactは親がレンダリングされると、子コンポーネントを無条件にレンダリングします。
2.検証ツールの使い方
本記事では二つのツールとライブラリを用いて再レンダリングの測定を行います。
よくある説明ではconsole.log
をあらかじめ仕込んで再レンダリングの発生を確認するものが多いですが、実際は、検証→課題を見つける→デバッグを行うというフローが定石です。
ここでは無駄な再レンダリングが発生しているコード例をもとに、
以下を使って検証と改善を行なっていきます。
- React Developer Tools
- why-did-you-render
検証ツール確認用コード
ブランチ名:chapter_2
import { useState } from "react";
const Child = () => {
console.log("子が再レンダリング");
return (
<>
<p>子コンポーネントが表示されています</p>
</>
);
};
export default function Parent() {
console.log("親が再レンダリング!");
const [text, setText] = useState("");
const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
return (
<>
<p>親コンポーネントで文字を入力</p>
<input type="text" onChange={changeText} />
<Child />
</>
);
}
コード例は前章「②親コンポーネントが再レンダリングされた時」で使用したものになります。
※実際の画面や動作は既出なので割愛
※console.log
も残してあります。
React Developer Toolsの概要
このReactDeveloperToolsはGoogleChromeの拡張機能です。
DeveloperToolに2つcomponentタブとProfilerタブが追加されて、
以下のようなReactのコンポーネントやレンダリングの状況を検証することができます。
- コンポーネント階層構造
- Props・Stateの値
- レンダリングの可視化・回数
初期設定
Profilerタブ→歯車(View Settings)→Generalと進み、
Highlight updates when components renderをクリックします。
検証
以下の流れで操作を行なっています。
①Reload and start profilingを押す
②右上のコミットバーの数を確認
③Ranked chartを押してChild
コンポーネントを確認
④再度Reload and start profilingを押す
⑤画面操作(文字入力)
⑥ハイライトされている(再レンダリングされいている)箇所を確認
・Highlight updates when components renderの機能がこちらです
・再レンダリング(文字入力)が行われる度に該当箇所が青く表示されます
⑦右上のコミットバーの数の増加を確認
・コミット(ReactがDOMに変更を適用する)ごとにパフォーマンス情報をグループ化
・現在選択しているコミットが青色
・各バーの色と高さは、そのコミットのレンダリングにかかった時間を示す
⑧③を再度試しChild
コンポーネントがレンダリングされていることを確認
前述のフローから以下の点から無駄な再レンダリングが発生していることがわかります。
- ⑤ ~ ⑥で文字入力が行われる度に
Child
コンポーネントもハイライトが起きている - ⑦ ~ ⑧でも文字入力が行われている度に
Child
コンポーネントがレンダリングされている
上記を鑑みて以下のことがわかります。
・親コンポーネントのstateの更新が発生するた度に子コンポーネントで再レンダリングが発生している。
・子コンポーネントではPropsに値が渡されていないため初期レンダリング以外は親コンポーネントのレンダリングの影響を受けて欲しくない。
・子コンポーネント側で何かしらの対策(memo化)を行なえそう。
Why Did You Renderの概要
このライブラリを導入することで回避可能な再レンダリングが発生した場合、consoleにログを出力します。
具体的には以下のような内容です。
- 再レンダリングが発生しているComponent名
- 再レンダリングが発生している原因
- 再レンダリングのトリガーになったpropsやstateの中身
使い方としては、
①React Developer Tools等でパフォーマンスが悪い箇所を発見して当該コンポーネントに埋め込む
②新規にコンポーネント作成の際に埋め込んだ状態で開発を進めて確認する
といった使い方が想定されます。
初期設定
お手元ですぐに動作検確認するために
冒頭でも説明した通り、このライブラリーがインストール済みのプロジェクトをVercelが公開しております。また、本記事でもこのSampleをベースに例題を作成しています。こちらをクローンしてコピペしてください。
それでは、さきほどのコード「検証ツール確認用コード」の最終行のコメントアウトを解除してみてください。
// 最終行をコメントアウトする
Child.whyDidYouRender = true
検証
先ほどと同様にいinput
欄に文字を入力してみるとconsole画面に何やら文字がたくさん表示されています。
Re-rendered because the props object itself changed but its values are all equal.'
Rendered by Parentfather from re-rendering.'
[hook useState result]
{"prev ": ''} '!==' {"next ": 'a'}
このアラートから以下のことが読み取れます。
- 値は等しいけれどpropsオブジェクト自体が更新されたため、Childコンポーネントは再レンダリングしている
- 親が再レンダリングされたことによって再レンダリングしている
- useStateの値が
a
に変わったことが要因
改善(React.memoでメモ化を行う)
ブランチ名:chapter_2_refactoring
React.memoとはコンポーネント(コンポーネントのレンダリング結果)をメモ化するReactのAPI(メソッド)になります。
親から受け取るPropsの等価性=変化があるかをモニタリングしており、propsに変化がなければコンポーネントの再レンダリングをスキップすることができます。
先に示したサンプル例を以下のようにReact.memoラップしてあげます。
import React from "react";
import { useState } from "react";
const Child = React.memo(() => {
console.log("子が再レンダリング");
return (
<>
<p>子コンポーネントが表示されています</p>
</>
);
});
// ①esLintエラー対策
Child.displayName = 'Child';
export default function Parent() {
console.log("親が再レンダリング!");
const [text, setText] = useState("");
const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
return (
// ②Reactフラグメントを削除してdivタグを追加
<div>
<p>親コンポーネントで文字を入力</p>
<input type="text" onChange={changeText} />
<Child />
</div>
);
}
// whyDidYouRenderで利用します
Child.whyDidYouRender = true
補足
①React.memoを利用するとdisplayNameを検知できなくなるため改めて明示的に定義する必要があります。未定義だとeslintによるエラーが表示されます。
②Reactフラグメントがあると子コンポーネントをメモ化しているのにも関わらず、
React Developer ToolsのHighlight updates when components render機能で確認しても再レンダリングされているように表示されてしまいます。
対応としてはFragment箇所を削除、<div>
タグ等に変換することで対応できます。
再検証
それでは修正した結果でそれぞれの検証ツールでどのような変化があったのか確認します。
React Developer Toolsの再検証
修正前は文字入力(再レンダリング)が行われる度に左側のハイライトがされていた箇所が少なくなっていることがわかります。
また、コミットごとに表示されていたChild
コンポーネントの表示数も無くなりました。
Why Did You Renderの再検証
こちらは非常にわかりやすくmemo化したことによりconsole
に表示されていたエラーが消えています。
3.簡単なケース問題
こちらではReact.memo
useCallback
useMemo
3つを利用してレンダリング最適化を行います。
これまでと同様以下の流れで進めます。
- 無駄なレンダリング・パフォーマンスが悪いコード例
- 検証ツールを用いて問題箇所を計測する
2-1. React Developer Tools
2-2. why-did-you-render - コード修正
- 再検証
無駄なレンダリング・パフォーマンスが悪いコード例
ブランチ名:chapter_3
import React from "react";
import { useState } from "react";
// ①Propsの受け取りはない子コンポーネント
const Child_1 = () => {
return (
<p>Child_1コンポーネント</p>
);
};
// ②callback関数をPropsで受け取っている子コンポーネント
const Child_2 = (props: { handleClick: () => void }) => {
return (
<p>Child_2コンポーネント</p>
// <button onClick={props.handleClick}>Child_2コンポーネント</button>
);
};
export default function Parent() {
const [text, setText] = useState("");
const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
const handleClick = () => {
console.log("click");
};
const [count, setCount] = useState(0);
const double = (count: number) => {
let i = 0;
while (i < 1000000000) i++;
return count * 2;
};
// ③計算に時間が掛かる重い処理結果を格納している値
const doubledCount = double(count);
return (
<div>
<p>親コンポーネントで文字を入力</p>
<input type="text" onChange={changeText} />
<Child_1 />
<Child_2 handleClick={handleClick} />
<p>親コンポーネント側での重い処理</p>
<p>
Counter: {count}, {doubledCount}
</p>
<button onClick={() => setCount(count + 1)}>Increment count2</button>
</div>
);
}
Child_1.whyDidYouRender = true;
Child_2.whyDidYouRender = true;
①Child_1
はPropsの受け渡しはないシンプルなコンポーネントです。
②Child_2
は親コンポーネントからcallback関数を受け取り、子コンポーネント内で利用しています。
③は親コンポーネントで利用している値になります。計算するの非常に時間が掛かる重い処理になります。
検証ツールを用いて問題箇所を計測する
React Developer Tools
実際にお手元で動かしてみれば分かりますがinput項目に文字を一文字入力するだけでも非常に重い動きになっています。
Ranked chartをでもParent
コンポーネントが圧倒的にパフォーマンスのボトルネックになっていることがわかります。
さらにParent
コンポーネントが再レンダリングされる度にChild_1
Child_2
も合わせてレンダリングされており無駄が発生していそうです。
まとめると以下の仮説が立ちました。
- 親コンポーネント側での処理がパフォーマンスのボトルネックになっている。
- 親コンポーネントの再レンダリングに関係のない子コンポーネントも影響されてしまっている
why-did-you-render
React Developer Toolsで立てた仮説を同ライブラリを用いてさらに検証してみます。
consoleに出力されている内容を整理すると以下の通りです。
Child_1
- 親コンポーネントでの
useState
の値が からaに変わったことが要因変更されたことによって再レンダリングした - propsの値自体は変わっていない
Child_2
再レンダリング理由は以下の二つ
- 親コンポーネントでの
useState
の値が - propsの値である
handleClick
が変化した
コード修正
ブランチ名:chapter_3_refactoring
import React from "react";
import { useState, useCallback, useMemo } from "react";
// ①コンポーネントをメモ化する
const Child_1 = React.memo(() => {
return (
<p>Child_1コンポーネント</p>
);
});
const Child_2 = React.memo((props: { handleClick: () => void }) => {
return (
<p>Child_2コンポーネント</p>
// <button onClick={props.handleClick}>Child_2コンポーネント</button>
);
});
export default function Parent() {
const [text, setText] = useState("");
const changeText = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
// ②コールバック関数のメモ化
const handleClick = useCallback(() => {
console.log("click");
},[]);
// ③重い処理をメモ化
const [count, setCount] = useState(0);
const double = (count: number) => {
let i = 0;
while (i < 1000000000) i++;
return count * 2;
};
const doubledCount = useMemo(() => double(count),[count]);
return (
<div>
<p>親コンポーネントで文字を入力</p>
<input type="text" onChange={changeText} />
<Child_1 />
<Child_2 handleClick={handleClick} />
<p>重い処理</p>
<p>
Counter: {count}, {doubledCount}
</p>
<button onClick={() => setCount(count + 1)}>Increment count2</button>
</div>
);
}
Child_1.displayName = 'Child_1';
Child_2.displayName = 'Child_2';
Child_1.whyDidYouRender = true;
Child_2.whyDidYouRender = true;
親子間の不要な再レンダリングを防ぐ
①検証時で明らかになったようにChild_1
Child_2
コンポーネントは親に影響されて無駄な再レンダリングが発生していました。
React.memo
を使いPropsの値が更新される時以外のレンダリングをスキップするようにします。
②関数はコンポーネントが再レンダリングされる度に再生成されてしまいます。
関数の処理が同じでも、新しいhandleClickと前回のhandleClickは異なるオブジェクトなので等価ではありません。そのため、コンポーネントが再レンダリングされてしまいます。
そこでuseCallback
を利用して関数をメモ化します。
親側の処理を軽くする
③doubledCount
は一定時間要するループ後にcount
を2倍した結果が格納されます。本来であれば以下のボタンがクリックされてcount
が更新される時のみ処理が実行してほしいはずです。
<button onClick={() => setCount(count + 1)}>Increment count2</button>
useMemo
を利用することで、引数の依存配列で指定したcount
に変化が起きる時以外はメモ化された値を利用するようにします。
再検証
React Developer Tools
親子間の不要な再レンダリングを防げた!!
Child_1
Child_2
コンポーネントのレンダリングが減っていることがわかります。
親側の処理を軽くできた!!
初回のParent
コンポーネントのレンダリング以降はメモ化された値を利用しているためレンダリングに掛かる時間が大幅に削減されていることがわかります。
why-did-you-render
親側でinput入力を行なっても(stateを更新)、consoleエラーが表示されなくなりました。
参考記事