LoginSignup
1112
774

More than 1 year has passed since last update.

React.memo / useCallback / useMemo の使い方、使い所を理解してパフォーマンス最適化をする

Last updated at Posted at 2020-05-24

はじめに

React(v16.12.0)のReact.memouseCallbackuseMemoの基本的な使い方、使い所に関しての備忘録です。

  • 「React でのパフォーマンス最適化の手段を知りたい」
  • 「なぜReact.memouseCallbackuseMemoを利用するのかわからない」

といった人達向けに書いた記事です。

デモは CodeSandbox 上に置いてあります。編集して動作を確認してみると理解が深まると思います。

本記事で用いている用語

  • メモ化
  • 計算結果

メモ化

計算結果を保持し、それを再利用する手法のこと。

キャッシュのようなものだとイメージすれば良いと思う。

そのため、以下の言葉の意味は大体同じ。

  • 「メモ化された値」=「計算結果が保持された値」
  • 「メモ化する」=「計算結果を再利用できるように保持する」

メモ化によって都度計算する必要がなくなるため、パフォーマンスの向上が期待できる。

計算結果

以下のような計算の結果のこと。

// result は 1 + 2 の計算結果を格納している変数
const result = 1 + 2;

// result2 は [1, 2, 3, 4, 5].map(number => number * 2) の計算結果を格納している変数
const result2 = [1, 2, 3, 4, 5].map(number => number * 2);

// result3 は React.createElement("div", null, `Hello ${this.props.name}`) の計算結果を格納している変数
const result3 = React.createElement("div", null, `Hello ${this.props.name}`);

React におけるパフォーマンス最適化

React では、不要な再計算やコンポーネントの再レンダリングを抑えることが、パフォーマンス最適化の基本的な戦略となる。

それらを実現する手段としてReact.memouseCallbackuseMemoを利用する。

React 以外のパフォーマンスチューニングにも言えることだが、計測は必須。

無闇に利用してもパフォーマンスが向上するわけではなく、意味がない場合もあるため注意。

React.memo

コンポーネント(コンポーネントのレンダリング結果)をメモ化する React の API(メソッド)。

コンポーネントをメモ化することで、コンポーネントの再レンダリングをスキップできる。

なぜ React.memo を利用するのか

以下のようなコンポーネントの再レンダリングをスキップすることで、パフォーマンスの向上が期待できるから。

  • レンダリングコストが高いコンポーネント
  • 頻繁に再レンダリングされるコンポーネント内の子コンポーネント

通常のコンポーネントに対しては、わざわざReact.memoを利用する必要はない。

React.memo の構文

React.memo(コンポーネント);

例えば、Helloというコンポーネントをメモ化する場合は以下のようになる。

const Hello = React.memo(props => {
  return <h1>Hello {props.name}</h1>;
});

React.memoは Props の等価性(値が等価であること)をチェックして再レンダリングの判断をする。

新しく渡された Props と前回の Props を比較し、等価であれば再レンダリングをせずにメモ化したコンポーネントを再利用する。

そのため、上記のHelloコンポーネントの場合、props.nameが更新されない限りコンポーネントは再レンダリングされない。

React.memo の利用例

React.memoを利用する場合と、しない場合では何が違うのか比較してみる。

React.memo を利用しない場合

通常、コンポーネントの state が更新されると、そのコンポーネントは再レンダリングされる。

以下のデモのように親コンポーネントが再レンダリングされると、その子コンポーネントも常に再レンダリングされる。
react-memo.gif
デモを見る

App.js
import React, { useState } from "react";

const Child = props => {
  console.log("render Child");
  return <p>Child: {props.count}</p>;
};

export default function App() {
  console.log("render App");
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  return (
    <>
      <button onClick={() => setCount1(count1 + 1)}>countup App count</button>
      <button onClick={() => setCount2(count2 + 1)}>countup Child count</button>
      <p>App: {count1}</p>
      <Child count={count2} />
    </>
  );
}

これが通常の挙動なので、この書き方が悪いわけではなく、問題もない。

コンポーネントの不要な再レンダリングでパフォーマンスの問題が発生した場合、React.memoの利用を検討する。

今回はChildコンポーネントが常に再レンダリングされても何も問題はないため、React.memoを利用する必要はない。

React.memo を利用する場合

以下はReact.memoを利用し、Childコンポーネントの再レンダリングをスキップしているデモ。
using-react-memo.gif
デモを見る

App.js
import React, { useState } from "react";

const Child = React.memo(props => {
  console.log("render Child");
  return <p>Child: {props.count}</p>;
});

export default function App() {
  console.log("render App");

  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  return (
    <>
      <button onClick={() => setCount1(count1 + 1)}>countup App count</button>
      <button onClick={() => setCount2(count2 + 1)}>countup Child count</button>
      <p>App: {count1}</p>
      <Child count={count2} />
    </>
  );
}

count1を更新してAppコンポーネントを再レンダリングした時は、Childコンポーネントに渡される Props(count2)は更新されないため、再レンダリングはスキップされる。

Childコンポーネントに渡されるcount2が更新された時だけ、再レンダリングされるようになった。

レンダリングコストが高いコンポーネントをメモ化する

極端な例になるが、以下のデモのようにレンダリングコストが高いコンポーネントをメモ化することで、パフォーマンスの向上が期待できる。
using-react-memo-02.gif
デモを見る

App.js
import React, { useState } from "react";

const Child = React.memo(props => {
  let i = 0;
  while (i < 1000000000) i++;
  console.log("render Child");
  return <p>Child: {props.count}</p>;
});

export default function App() {
  console.log("render App");

  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  return (
    <>
      <button onClick={() => setCount1(count1 + 1)}>countup App count</button>
      <button onClick={() => setCount2(count2 + 1)}>countup Child count</button>
      <p>App: {count1}</p>
      <Child count={count2} />
    </>
  );
}

頻繁に再レンダリングされるコンポーネント内の子コンポーネントをメモ化する

以下のデモのように、頻繁に再レンダリングされるコンポーネント内の子コンポーネントをメモ化することで、パフォーマンスの向上が期待できる。
using-react-memo-03.gif
デモを見る

App.js
import React, { useState, useEffect, useRef } from "react";

const Child = React.memo(() => {
  console.log("render Child");
  return <p>Child</p>;
});

export default function App() {
  console.log("render App");

  const [timeLeft, setTimeLeft] = useState(100);
  const timerRef = useRef(null);
  const timeLeftRef = useRef(timeLeft);

  useEffect(() => {
    timeLeftRef.current = timeLeft;
  }, [timeLeft]);

  const tick = () => {
    if (timeLeftRef.current === 0) {
      clearInterval(timerRef.current);
      return;
    }
    setTimeLeft(prevTime => prevTime - 1);
  };

  const start = () => {
    timerRef.current = setInterval(tick, 10);
  };

  const reset = () => {
    clearInterval(timerRef.current);
    setTimeLeft(100);
  };

  return (
    <>
      <button onClick={start}>start</button>
      <button onClick={reset}>reset</button>
      <p>App: {timeLeft}</p>
      <Child />
    </>
  );
}

コールバック関数を Props として受け取ったコンポーネントは必ず再レンダリングされる

以下のデモのようにコールバック関数を受け取ったコンポーネントはReact.memoを利用しても必ず再レンダリングされる。
using-react-memo-04.gif
デモを見る

App.js
import React, { useState } from "react";

const Child = React.memo(props => {
  console.log("render Child");
  return <button onClick={props.handleClick}>Child</button>;
});

export default function App() {
  console.log("render App");

  const [count, setCount] = useState(0);
  // 関数はコンポーネントが再レンダリングされる度に再生成されるため、
  // 関数の内容が同じでも、新しい handleClick と前回の handleClick は
  // 異なるオブジェクトなので、等価ではない。
  // そのため、コンポーネントが再レンダリングされる。
  const handleClick = () => {
    console.log("click");
  };

  return (
    <>
      <p>Counter: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment count</button>
      <Child handleClick={handleClick} />
    </>
  );
}

以下のように参照が異なる関数は別のオブジェクトとなる。

function doSomething() {
  console.log("doSomething");
}
const func1 = doSomething;
const func2 = doSomething;
console.log(doSomething === doSomething); // true
console.log(func1 === func2); // true

const func3 = () => {
  console.log("doSomething");
};
const func4 = () => {
  console.log("doSomething");
};
console.log(func3 === func4); // false

前述のhandleClickが参照する関数も、Appコンポーネントが再レンダリングされる度に再生成されるため、等価ではない。

そのため、関数の内容が同じでもChildコンポーネントが再レンダリングされる。

この問題を解消するためには、useCallbackを利用して関数をメモ化する必要がある。

useCallback

メモ化されたコールバック関数を返すフック。

なぜ useCallback を利用するのか

React.memoと併用することで、コンポーネントの不要な再レンダリングをスキップできるから。

より具体的に言えば、React.memoでメモ化したコンポーネントにuseCallbackでメモ化したコールバック関数を Props として渡すことで、コンポーネントの不要な再レンダリングをスキップできるから。

useCallback の構文

useCallback(コールバック関数, 依存配列);

依存配列とは、コールバック関数が依存している要素が格納された配列のこと。

例えば、countという変数をconsole.logで出力する関数をメモ化したい場合は以下のようになる。

const callback = useCallback(() => console.log(count), [count]);

依存している要素が更新されれば、関数が再生成される。

依存している要素がなければ、依存配列は空で OK。

const callback = useCallback(() => console.log("doSomething"), []);

useCallback の利用例

以下はメモ化したコールバック関数を渡し、コンポーネントは再レンダリングをスキップしているデモ。
using-usecallback.gif
デモを見る

App.js
import React, { useState, useCallback } from "react";

const Child = React.memo(props => {
  console.log("render Child");
  return <button onClick={props.handleClick}>Child</button>;
});

export default function App() {
  console.log("render App");

  const [count, setCount] = useState(0);
  // 関数をメモ化すれば、新しい handleClick と前回の handleClick は
  // 等価になる。そのため、Child コンポーネントは再レンダリングされない。
  const handleClick = useCallback(() => {
    console.log("click");
  }, []);

  return (
    <>
      <p>Counter: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment count</button>
      <Child handleClick={handleClick} />
    </>
  );
}

useCallback の注意点

前述の通り、useCallbackReact.memoと併用するものなので、以下のような使い方をしても意味がない(コンポーネントの不要な再レンダリングをスキップできない)ので注意。

  • React.memoでメモ化をしていないコンポーネントにuseCallbackでメモ化をしたコールバック関数を渡す
  • useCallbackでメモ化したコールバック関数を、それを生成したコンポーネント自身で利用する

React.memo でメモ化をしていないコンポーネントに useCallback でメモ化をしたコールバック関数を渡す

以下のように、メモ化をしていないコンポーネントにメモ化をしたコールバック関数を渡しても、コンポーネントは常に再レンダリングされてしまう。

import React, { useState, useCallback } from "react";

// React.memo でメモ化をしていないコンポーネントのため、メモ化されたコールバック関数を渡されても意味がない。
// App コンポーネントがレンダリングされる度に再レンダリングされる。
const Child = props => {
  console.log("render Child");
  return <button onClick={props.handleClick}>Child</button>;
};

export default function App() {
  console.log("render App");

  const [count, setCount] = useState(0);
  const handleClick = useCallback(() => {
    console.log("click");
  }, []);

  return (
    <>
      <p>Counter: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment count</button>
      <Child handleClick={handleClick} />
    </>
  );
}

useCallback でメモ化したコールバック関数を、それを生成したコンポーネント自身で利用する

以下の例では、メモ化したコールバック関数をAppコンポーネント自身で利用している。

動作はするが、「コンポーネントの再レンダリングをスキップする」という目的を達成できてない。

import React, { useState, useCallback } from "react";

export default function App() {
  console.log("render App");

  const [count, setCount] = useState(0);
  const handleClick = useCallback(() => {
    console.log("memonized callback");
  }, []);

  return (
    <>
      <p>Counter: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment count</button>
      <button onClick={handleClick}>logging</button>
    </>
  );
}

useMemo

メモ化された値を返すフック。

コンポーネントの再レンダリング時に値を再利用できる。

なぜ useMemo を利用するのか

値の不要な再計算をスキップすることで、パフォーマンスの向上が期待できるから。

useMemo の構文

useMemo(() => 値を計算するロジック, 依存配列);

依存配列とは、値を計算するロジックが依存している要素(値の計算に必要な要素)が格納された配列のこと。

例えば、countという変数の値を2倍にした値をメモ化したい場合は以下のようになる。

const result = useMemo(() => count * 2, [count]);

依存している要素が更新されれば、値が再計算される。

useMemo の利用例

useMemoを利用する場合と、しない場合では何が違うのか比較してみる。

useMemo を利用しない場合

以下はuseMemoを利用せず、不要な再計算が発生しているデモ
unused-usememo.gif
デモを見る

App.js
import React, { useState } from "react";

export default function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  // 引数の数値を2倍にして返す。
  // 不要なループを実行しているため計算にかなりの時間がかかる。
  const double = count => {
    let i = 0;
    while (i < 1000000000) i++;
    return count * 2;
  };

  // count2 を2倍にした値
  // double(count2) はコンポーネントが再レンダリングされる度に実行されるため、
  // count1 を更新してコンポーネントが再レンダリングされた時にも実行されてしまう。
  // そのため、count1 を更新してコンポーネントを再レンダリングする時も時間がかかる。
  // count1 を更新しても doubledCount の値は変わらないため、count1 を更新した時に
  // double(count2) を実行する意味がない。したがって、不要な再計算が発生している状態である。
  // count1 が更新されてコンポーネントが再レンダリングされた時は double(count2) が実行されないようにしたい。
  const doubledCount = double(count2);

  return (
    <>
      <h2>Increment count1</h2>
      <p>Counter: {count1}</p>
      <button onClick={() => setCount1(count1 + 1)}>Increment count1</button>

      <h2>Increment count2</h2>
      <p>
        Counter: {count2}, {doubledCount}
      </p>
      <button onClick={() => setCount2(count2 + 1)}>Increment count2</button>
    </>
  );
}

count1を更新した時もdouble(count2)が実行されてしまうため、count1を更新してコンポーネントを再レンダリングする時も時間がかかる。

useMemo を利用する場合

以下はuseMemoを利用し、不要な再計算をスキップするデモ。
using-usememo.gif
デモを見る

App.js
import React, { useState, useMemo } from "react";

export default function App() {
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  // 引数の数値を2倍にして返す。
  // 不要なループを実行しているため計算にかなりの時間がかかる。
  const double = count => {
    let i = 0;
    while (i < 1000000000) i++;
    return count * 2;
  };

  // count2 を2倍にした値をメモ化する。
  // 第2引数に count2 を渡しているため、count2 が更新された時だけ値が再計算される。
  // count1 が更新され、コンポーネントが再レンダリングされた時はメモ化した値を利用するため再計算されない。
  const doubledCount = useMemo(() => double(count2), [count2]);

  return (
    <>
      <h2>Increment(fast)</h2>
      <p>Counter: {count1}</p>
      <button onClick={() => setCount1(count1 + 1)}>Increment(fast)</button>

      <h2>Increment(slow)</h2>
      <p>
        Counter: {count2}, {doubledCount}
      </p>
      <button onClick={() => setCount2(count2 + 1)}>Increment(slow)</button>
    </>
  );
}

useMemoを利用して値をメモ化したため、count1を更新した時はdouble(count2)が実行されないようになった。

そのため、count1を更新した時のコンポーネントの再レンダリングが高速になった。

コンポーネントの再レンダリングをスキップする

useMemoはレンダリング結果もメモ化できるため、React.memoのようにコンポーネントの再レンダリングをスキップできる。

以下はコンポーネントをメモ化して、不要な再レンダリングをスキップしているデモ。
usememo-02.gif
デモを見る

App.js
import React, { useState, useMemo } from "react";

export default function App() {
  console.log("render App");
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  // 引数の数値を2倍にして返す。
  // 無駄なループを実行しているため計算にかなりの時間がかかる。
  const double = count => {
    let i = 0;
    while (i < 1000000000) i++;
    return count * 2;
  };

  // レンダリング結果(計算結果)をメモ化する
  // 第2引数に count2 を渡しているため、count2 が更新された時だけ再レンダリングされる。
  // count1 が更新され、コンポーネントが再レンダリングされた時はメモ化したレンダリング結果を
  // 利用するため再レンダリングされない。
  const Counter = useMemo(() => {
    console.log("render Counter");
    const doubledCount = double(count2);

    return (
      <p>
        Counter: {count2}, {doubledCount}
      </p>
    );
  }, [count2]);

  return (
    <>
      <h2>Increment count1</h2>
      <p>Counter: {count1}</p>
      <button onClick={() => setCount1(count1 + 1)}>Increment count1</button>

      <h2>Increment count2</h2>
      {Counter}
      <button onClick={() => setCount2(count2 + 1)}>Increment count2</button>
    </>
  );
}

関数コンポーネント内でコンポーネントをメモ化したい場合はuseMemoを利用する。

以下のデモのように関数コンポーネント内でReact.memoを利用しても意味がないので注意。
meaningless-reactmemo.gif
デモを見る

App.js
import React, { useState } from "react";

export default function App() {
  console.log("render App");
  const [count1, setCount1] = useState(0);
  const [count2, setCount2] = useState(0);

  // 引数の数値を2倍にして返す。
  // 無駄なループを実行しているため計算にかなりの時間がかかる。
  const double = count => {
    let i = 0;
    while (i < 1000000000) i++;
    return count * 2;
  };

  // App コンポーネントが再レンダリングされたら
  // このコンポーネントも必ず再レンダリングされる
  const Counter = React.memo(props => {
    console.log("render Counter");
    const doubledCount = double(props.count2);

    return (
      <p>
        Counter: {props.count2}, {doubledCount}
      </p>
    );
  });

  return (
    <>
      <h2>Increment count1</h2>
      <p>Counter: {count1}</p>
      <button onClick={() => setCount1(count1 + 1)}>Increment count1</button>

      <h2>Increment count2</h2>
      <Counter count2={count2} />
      <button onClick={() => setCount2(count2 + 1)}>Increment count2</button>
    </>
  );
}

useCallback を関数の再生成を防ぐ目的で利用してはいけないのか?

useMemoが再計算を防ぐために利用するのであれば、useCallbackも関数の再生成を防ぐために利用するのは意味があるのでは?」と思った方もいると思います。

しかし、私はそれを目的として利用することは理にかなっていないと思っています。

なぜなら、「関数の再生成するコスト > useCallbackの実行コスト」になることはないと認識しているからです。

useCallbackuseMemoのどちらも、メモ化をする処理自体にコストがあります。

useMemo の場合

useMemoの場合、「再計算のコスト < `useMemo`の実行コスト」の時もあれば、「再計算のコスト > useMemoの実行コスト」の時もあると認識しています。

極端な例になりますが、以下は「再計算のコスト < useMemoの実行コスト」の例です。

const result = useMemo(() => value * 2, [value]);

value * 2は単純な計算なので、useMemoを利用しても効果はありません。

寧ろ、useMemoの実行コストの方が高いかもしれないため、上記のようなシーンでuseMemoを利用するのは理にかなっていないと思っています。

そして、以下は「再計算のコスト > useMemoの実行コスト」の例です。

const result = useMemo(() => {
  let i = 0;
  while (i < 1000000000) i++;
  count * 2;
}, [value]);

この場合、明らかに再計算のコストの方が高いため、useMemoを利用すると大きな効果が得られます。

useCallback の場合

以下は「関数の再生成するコスト < useCallbackの実行コスト」の例です。

const handleClick = useCallback(() => {
  console.log(value);
}, [value]);

前述のuseMemoと同様で、わざわざ利用する必要がないと思っています。

そして、「関数の再生成するコスト > useCallbackの実行コスト」ですが、この状況が思いつかないです。

// ?

なので、関数の再生成を防ぐためにuseCallbackを利用するの理にかなってないし、わざわざ利用する必要はないと思っています。

厳密な測定をしたわけではないので、この認識は間違っているかもしれないです。

もし、私の認識が間違っている(「関数の再生成するコスト > useCallbackの実行コスト」になる状況がある、もしくは他に使い道がある)場合、お手数ですが具体例(コード)を添えて、ご意見ご指摘いただけると大変助かります。

依存配列は正しく指定する必要がある

useCallbackuseMemoの依存配列は正しく指定しないとバグの原因になる。

そのため、以下のコードは NG。

// 依存要素である count2 が依存配列にないため NG
const result = useMemo(() => count * count2, [count]);

// これが正しい
// const result = useMemo(() => count * count2, [count, count2]);

そのため、eslint-plugin-react-hooksなどを利用して、必ず Lint する。

使い所

パフォーマンスを計測し、ボトルネックになっている箇所に適用していくのが一番効果的。

とは言え、GitHub 上にあるコードや技術書を見てみると、ガンガン利用していた。

厳密な利用基準を設けるのも大変なので、それぞれの機能や役割をちゃんと理解しているのであれば、積極的に利用しても大きな問題はないと思った。

終わり

今回準備したデモは極端な例ですが、利用シーンによっては非常に有用な機能です。

状況に応じて利用していきましょう。

本記事以外にも React に関連する記事を書いておりますので、興味があればそちらもどうぞ。

お知らせ

Udemy で webpack の講座を公開したり、Kindle で技術書を出版しています。

Udemy:
webpack 最速入門10,800 円 -> 2,000 円

Kindle(Kindle Unlimited だったら無料):
React Hooks 入門(500 円)

興味を持ってくださった方はご購入いただけると大変嬉しいです。よろしくお願いいたします。

1112
774
8

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1112
774