【React】レンダリングの最適化について考える
こんにちは! スマレジ テックファームのMichiです!
今回は、Reactのレンダリング最適化について自分なりに調べたことをまとめていきます。
Reactのコンポーネントが再レンダリングされるとき
まずおさらいですが、Reactのコンポーネントが再レンダリングされるタイミングについて確認しておきます。
なお、このほかによく挙げられるのが「propsが更新されたとき」ですが、これは厳密に言えば間違いです。
なぜなら、props を変更するには親コンポーネントが更新される必要があるからです。逆に、親コンポーネントが更新されなければ、propsが変わることはあり得ません。
つまり、「propsが更新されたから再レンダリングされる」のではなく、「親コンポーネントが再レンダリングされたから、子コンポーネントも再レンダリングされる」が正しいです。
(ちなみに、この点については自分の過去の記事でも間違えていますね。)
レンダリングの最適化方法
さて、ここからが本題です。
レンダリングの最適化方法について、「コンポーネント分割」「childrenの使用」「メモ化」という3つの視点で見ていきます。
コンポーネントを適切に分割する
悪い例
次に示すのは、簡単なTODOリストをReactで実装した例です。
■TodoList.tsx
import { useState } from "react"; import "./styles.css"; export default function TodoList() { const [value, setValue] = useState(""); const [taskList, setTaskList] = useState<string[]>([]); const handleSubmit = () => { setTaskList([...taskList, value]); setValue(""); }; return ( <div className="todo"> <div> <input type="text" value={value} onChange={(e) => setValue(e.target.value)} /> <button className="btn" onClick={handleSubmit}> 作成 </button> </div> <div> <ul> {taskList.map((task, i) => ( <li key={i}>{task}</li> ))} </ul> </div> </div> ); }
フォームに文字を入力し、「作成」ボタンをクリックすると、リストに新規TODOが登録されるだけの単純な作りです。
この実装では、フォームに値を入力するたびに、画面全体が再レンダリングされます。なぜなら、フォームの入力値value
はTodoList.tsx
内でステート管理されており、value
の値が変更されるたびにコンポーネント全体が再レンダリングされるからです。
しかし、フォームから下のTODOリストは、「作成」ボタンをクリックして新規TODOを追加しないかぎりは、表示される内容は変わりません。ということは、リストについては毎回無駄なレンダリングが発生しているといえます。
改善後
無駄なレンダリングを回避するために、コンポーネントを分割してステートのリフトダウンを検討します。
■TodoList.tsx
import { useState } from "react"; import "./styles.css"; import Input from "./Input"; export default function TodoList() { const [taskList, setTaskList] = useState<string[]>([]); return ( <div className="todo"> <Input taskList={taskList} setTaskList={setTaskList} /> <div> <ul> {taskList.map((task, i) => ( <li key={i}>{task}</li> ))} </ul> </div> </div> ); }
■Input.tsx
import { useState } from "react"; import "./styles.css"; type InputProps = { taskList: string[]; setTaskList: (taskList: string[]) => void; }; export default function Input({ taskList, setTaskList }: InputProps) { const [value, setValue] = useState(""); const handleSubmit = () => { setTaskList([...taskList, value]); setValue(""); }; return ( <div className="todo"> <div> <input type="text" value={value} onChange={(e) => setValue(e.target.value)} /> <button className="btn" onClick={handleSubmit}> 作成 </button> </div> </div> ); }
コンポーネントを分割し、value
のステート管理をInput.tsx
へ委譲します。今度はフォームに値を入力しても、レンダリングされるのはInput.tsx
だけで、画面全体はレンダリングされません。
結果的に、コンポーネントを分割することで不要な再レンダリングを防ぐことができました。
childrenを使う
悪い例
先ほどの例に、「つかいかた」の説明を追加します。
■TodoList.tsx
import { useState } from "react"; import "./styles.css"; import Input from "./Input"; export default function TodoList() { const [taskList, setTaskList] = useState<string[]>([]); return ( <div className="todo"> <Input taskList={taskList} setTaskList={setTaskList} /> <div className="how-to-use"> <h3>つかいかた</h3> <p> 上部のフォームに追加したいTODOを入力し、作成ボタンをクリックしてください </p> <p>---------------------------------------------</p> </div> <div> <ul> {taskList.map((task, i) => ( <li key={i}>{task}</li> ))} </ul> </div> </div> ); }
※そもそもUIとして変ですが、解説用としてご理解ください
「つかいかた」は静的なデータなので、初回レンダリング以降データの内容が変わることはありません。しかしながら、新しいTODOを追加してtaskList
の状態が更新されるたびに、コンポーネント全体がレンダリングされるので、この静的な情報の部分もレンダリングされてしまいます。
改善後
まず、「つかいかた」の部分を別コンポーネントへ分離します。
■HowToUse.tsx
export default function HowToUse() { return ( <div className="how-to-use"> <h3>つかいかた</h3> <p> 上部のフォームに追加したいTODOを入力し、作成ボタンをクリックしてください </p> <p>---------------------------------------------</p> </div> ); }
分離したHowToUse.tsx
は親コンポーネントからchildrenとして渡すようにします。
■App.tsx
import TodoList from "./TodoList"; import HowToUse from "./HowToUse"; export default function App() { return ( <TodoList> <HowToUse /> </TodoList> ); }
■TodoList.tsx
import { ReactNode, useState } from "react"; import "./styles.css"; import Input from "./Input"; type TodoListProps = { children: ReactNode }; export default function TodoList({ children }: TodoListProps) { const [taskList, setTaskList] = useState<string[]>([]); return ( <div className="todo"> <Input taskList={taskList} setTaskList={setTaskList} /> {children} <div> <ul> {taskList.map((task, i) => ( <li key={i}>{task}</li> ))} </ul> </div> </div> ); }
childrenはpropsの一種ですので、状態の変更の影響を受けません。(冒頭でも触れた通り、propsが更新されるのは、親コンポーネントがレンダリングされたときのみ。)
よって、TodoList.tsx
が何回レンダリングされようとも、children(HowToUse.tsx
)がレンダリングされることはないわけです。
メモ化する
「レンダリングの最適化」と聞いて、おそらくこれを一番にイメージする方が多いのではないでしょうか。
Reactにおけるメモ(データキャッシュ)化には3つのプロセスがあります。
memo
... コンポーネントのメモ化useCallback
... 関数のメモ化useMemo
... (関数以外の)値・オブジェクトのメモ化
使い方については、他にも多数解説記事が出ているので割愛します。
この方法をあえて最後に持ってきた理由は、安易にメモ化に頼るのは良くないと考えるからです。
メモ化のデメリットとしては、
- 本来、メモ化の依存配列に入れるべき値を入れ忘れて、バグの原因となる
- コードが冗長化して読みにくくなる
- メモ化自体のオーバーヘッドがある
などがあります。
メモ化する前に、先に紹介したコンポーネントの分割やchildrenの利用などで、レンダリング回数を減らせないか検討することが大事だと思います。
まとめ
この記事を書いたきっかけは次のツイートでした。
今の現場、Reactの中で何でもかんでもuseMemoしてるんやけどこれって普通なのか?ワイの中でuseMemoって、オブジェクトをpropsとして渡すときに渡し先のコンポーネントで再レンダリングを避けたい場合くらいしか使わんイメージなんやけど。みなさんどうですか?
— Michi@Webエンジニア (@Michi_program) 2023年9月27日
この考え方には、賛否両論ありそうなので、ご意見のある方はコメントやX(Twitter)などで教えていただけると嬉しいです!