🐙

【React】再レンダリングの仕組みと最適化

2021/03/16に公開3

React初心者です。
Reactのレンダリングについて学習したのでまとめてみました。

Reactが再レンダリングするタイミング

基本的にReactで再レンダリングが起きるタイミングは以下の3つ。

  1. stateが更新された時
  2. propsが更新された時
  3. 親コンポーネントが再レンダリングされた時

1. stateが更新された時

src/App.jsx
import { useState } from "react";

export default function App() {
  console.log("App");
  const [text, setText] = useState("");

  const changeText = (e) => {
    setText(e.target.value);
  };

  return (
    <>
      <p>App component</p>
      <input type="text" onChange={changeText} />
    </>
  );
}

上記のコードで空欄に文字を入力・削除してみると、処理回数に応じてconsoleに App が繰り返される。
すなわち、stateが変更されることで再レンダリングが発生している。

2. propsが更新された時

src/App.jsx
import { useState } from "react";
import Child from "./components/Child";

export default function App() {
  const [count, setCount] = useState(0);

  const countUp = () => {
    setCount(count + 1);
  };

  return (
    <>
      <p>App component</p>
      <button onClick={countUp}>count up</button>
      <Child count={count} />
    </>
  );
}
src/components/Child.jsx
const Child = (props) => {
  const { count } = props;
  console.log("Child");

  return (
    <>
      <p>Child component</p>
      {count}
    </>
  );
};

export default Child;

親であるAppコンポーネントから子であるChildコンポーネントへcountというpropsを渡している。
ブラウザで実行してcount upボタンを押すと数字が1ずつ増えていき、consoleにはChildが回数分表示される。
すなわち、propsが変更されることで再レンダリングが発生している。

3. 親コンポーネントが再レンダリングされた時

これは「親コンポーネントで再レンダリングが発生すると、その配下にある子コンポーネントが全て再レンダリングされてしまう」というもの。

src/App.jsx
import { useState } from "react";
import Child from "./components/Child";

export default function App() {
  console.log("App");
  const [text, setText] = useState("");

  const changeText = (e) => {
    setText(e.target.value);
  };

  return (
    <>
      <p>App component</p>
      <input type="text" onChange={changeText} />
      <br />
      <Child />
    </>
  );
}
src/components/Child.jsx
const Child = () => {
  console.log("Child");

  return (
    <>
      <p>Child component</p>
    </>
  );
};

export default Child;

上記のコードでは親子間でのpropsの受け渡しはないが、ブラウザ上で空欄に文字を入力・削除すると(親であるAppコンポーネントに記述されているinput要素に値を入力すると)、consoleにAppChildが繰り返し表示されることが確認できる。
すなわち、親コンポーネントが再レンダリングされているタイミングで子コンポーネントも再レンダリングされている。
親子間で値の受け渡しが無いのにも関わらず、意図せずこのような再レンダリングが発生してしまうことで、パフォーマンスが下がってしまう。

再レンダリングを最適化する

再レンダリングを最適化する、すなわち無駄な計算や処理を抑えるために必要なReactの機能が以下の3つ。

  1. memo
  2. useCallback
  3. useMemo

これらの機能を用いることで、メモ化(計算結果を保持し、それを再利用すること)ができる。
同じ結果を返す処理に関しては初回のみ処理を実行しておき、2回目以降は前回の処理結果を呼び出すことで毎回同じ処理を実行しなくてよくなる。

1. memo

以下のコードをブラウザで実行し、空欄に文字を入力・削除すると(親であるAppコンポーネントに記述されているinput要素に値を入力すると)、consoleにはAppのみが繰り返し表示される。(メモ化していない先ほどのコードではAppChildが交互に繰り返されていた)
Childコンポーネントはメモ化されているので、Childはconsoleに表示されない。

src/App.jsx
import { useState } from "react";
import Child from "./components/Child";

export default function App() {
  console.log("App");
  const [text, setText] = useState("");

  const changeText = (e) => {
    setText(e.target.value);
  };

  return (
    <>
      <p>App component</p>
      <input type="text" onChange={changeText} />
      <br />
      <Child />
    </>
  );
}
src/components/Child.jsx
import { memo } from "react";

const Child = memo(() => {
  console.log("Child");

  return (
    <>
      <p>Child component</p>
    </>
  );
});

export default Child;

2. useCallback

useCallbackはメモ化したコールバック関数を返すHooks API。
次に、以下のようなコードを実行してみる。

src/App.jsx
import { useState } from "react";
import Child from "./components/Child";

export default function App() {
  console.log("App");
  const [text, setText] = useState("");

  const changeText = (e) => {
    setText(e.target.value);
  };

  return (
    <>
      <p>App component</p>
      <br />
      <Child changeText={changeText} />
    </>
  );
}
src/components/Child.jsx
import { memo } from "react";

const Child = memo((props) => {
  console.log("Child");
  const { changeText } = props;

  return (
    <>
      <input type="text" onChange={changeText} />
      <p>Child component</p>
    </>
  );
});

export default Child;

先ほど親コンポーネント(App)で処理していたinput要素を子コンポーネント(Child)に移動させ、関数changeTextもpropsで受け渡している。
以下のコードをブラウザで実行し、空欄に文字を入力・削除すると(親であるAppコンポーネントに記述されているinput要素に値を入力すると)、再びconsoleにAppChildが繰り返し表示される。
Childコンポーネントをメモ化しているのになぜ?」

この原因はpropsで受け渡した関数にある。
親コンポーネントで生成した関数をpropsで子コンポーネントに渡すと、関数の内容が同じでも子コンポーネントでは「毎回新しい関数が渡されている」と判断されてしまう。
そこでuseCallbackを使う。

src/App.jsx
import { useState, useCallback } from "react";
import Child from "./components/Child";

export default function App() {
  console.log("App");
  const [text, setText] = useState("");

  const changeText = useCallback(
    (e) => {
      setText(e.target.value);
    },
    [setText]
  );

  return (
    <>
      <p>App component</p>
      <br />
      <Child changeText={changeText} />
    </>
  );
}

以上のように関数(changeText)をuseCallbackで囲み、第2引数には配列を設定できる。(useEffectと同様)
このコードをブラウザで実行し、空欄に文字を入力・削除すると(親であるAppコンポーネントに記述されているinput要素に値を入力すると)、consoleにはAppのみが繰り返し表示される。

3. useMemo

useMemoは変数のメモ化ができるHooks API。

src/App.jsx
import { useState } from "react";

export default function App() {
  console.log("App");
  const [text, setText] = useState("");

  const changeText = (e) => {
    setText(e.target.value);
  };

  const todayDate = () => {
    console.log("Date");
    const dateObj = new Date();
    const dateString = `${dateObj.getFullYear()}${
      dateObj.getMonth() + 1
    }${dateObj.getDate()}`; // YYYY年MM月DD日
    return <p>日付:{dateString}</p>;
  };

  return (
    <>
      <p>App component</p>
      <input type="text" onChange={changeText} />
      <br />
      {todayDate()}
    </>
  );
}

以上のコードをブラウザで実行し、空欄に文字を入力・削除すると(親であるAppコンポーネントに記述されているinput要素に値を入力すると)、consoleにAppDateが交互に繰り返し表示される。

src/App.jsx
import { useState, useMemo } from "react";

export default function App() {
  console.log("App");
  const [text, setText] = useState("");

  const changeText = (e) => {
    setText(e.target.value);
  };

  const todayDate = useMemo(() => {
    console.log("Date");
    const dateObj = new Date();
    const dateString = `${dateObj.getFullYear()}${
      dateObj.getMonth() + 1
    }${dateObj.getDate()}`; // YYYY年MM月DD日
    return <p>日付:{dateString}</p>;
  }, []);

  return (
    <>
      <p>App component</p>
      <input type="text" onChange={changeText} />
      <br />
      {todayDate}
    </>
  );
}

以上のように変数を返す関数(todayDate)をuseMemoで囲み、第2引数には配列を設定できる。(useEffectと同様)
このコードをブラウザで実行し、空欄に文字を入力・削除すると(親であるAppコンポーネントに記述されているinput要素に値を入力すると)、consoleにはAppのみが繰り返し表示される。

参考

Discussion

tktk

2. propsが更新された時のサンプルコードでChildが再レンダリングされているのはpropsが更新されたからではなく、AppでsetCountを実行してAppが再レンダリングされているからです。つまり、3. 親コンポーネントが再レンダリングされた時と同じ理由で再レンダリングされています。useStateでカウントせずにuseRefでカウントしたものをpropsとして渡すようにすると、propsが更新されているにもかかわらずChildが再レンダリングされないことを確認できます。すなわち2. propsが更新された時は誤りだとわかります。

ChiChi

確かにご指摘の通り2のpropsが更新された時の例は3. 親コンポーネントが再レンダリングされた時と同じになってしまってるのですが、オフィシャルにある通り基本的にpropsが更新されるとデフォルトでは再レンダリングされるので2. propsが更新された時は誤りではないという理解です。違ったらご指摘ください。

By default, when your component’s state or props change, your component will re-render.
React オフィシャル

tktk

親の立場で「あるデータをpropsとして子に渡す」と言えますが、それは「親のpropsではない」ですよね?用語集を見ても「propsはリードオンリーだ」とあるように子の立場としてみた場合にpropsと言っています。

Remember that props are readonly.
https://reactjs.org/docs/glossary.html#props

引用されている説明文はforceUpdate()のものですが、your componentとは子の立場になると思います。親がpropsとして渡しているstateを更新すると親の再レンダリングが発生し、それに伴って子が再レンダリングされますが、その際には子としてはpropsが変更されていると言えると思います。

一方で、本記事では「親から子に渡すpropsが変更された時に再レンダリングされる」という趣旨で2. propsが更新された時と書かれているのでそれは誤りだという指摘です。