Michi's Tech Blog

一人前のWebエンジニアを目指して

【React】レンダリングの最適化について考える

こんにちは! スマレジ テックファームのMichiです!

今回は、Reactのレンダリング最適化について自分なりに調べたことをまとめていきます。

Reactのコンポーネントが再レンダリングされるとき

まずおさらいですが、Reactのコンポーネントが再レンダリングされるタイミングについて確認しておきます。

  1. stateが更新されたとき
  2. コンポーネントが再レンダリングされたとき
  3. Contextが変更されたとき

なお、このほかによく挙げられるのが「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が登録されるだけの単純な作りです。

この実装では、フォームに値を入力するたびに、画面全体が再レンダリングされます。なぜなら、フォームの入力値valueTodoList.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の利用などで、レンダリング回数を減らせないか検討することが大事だと思います。

まとめ

この記事を書いたきっかけは次のツイートでした。

この考え方には、賛否両論ありそうなので、ご意見のある方はコメントやX(Twitter)などで教えていただけると嬉しいです!