パフォーマンス改善やバグ防止に理解必須。useEffect第2引数の利用パターン集

JavaScript

useEffectはReact Hooks APIの中でも使用頻度の高いフックです。
レンダリングのタイミングで副作用を実行したい場合はuseEffectを活用します。

useEffectには依存配列と呼ばれる第2引数を設定できます。第2引数はパフォーマンス改善やバグ防止の役割を持ちます。
今回は第2引数の話を中心にuseEffectの利用方法について紹介します。

useEffectについて

useEffectは副作用を実行するためのフックです。
クラスコンポーネントにおけるcomponentDidMountcomponentDidUpdatecomponentWillUnmountのライフサイクルをまとめたフックがuseEffectです。1

つまり、useEffectの実行は関数コンポーネントのレンダリングがトリガとなります。

例として、ボタンをクリックするとカウントが増えるコンポーネントを紹介します。
ボタンをクリックするとStateが変更するため、コンポーネントが再レンダリングされます。当該コンポーネントにuseEffectを設定した場合、クリックのタイミングに応じてuseEffectが実行されることになります。

import React, { useEffect, useState } from "react";
import "./App.css";

const App: React.FC = () => {
  const [countA, setCountA] = useState(0);

  // ボタンがクリックされるたびに呼び出される
  useEffect(() => {
    console.log("useEffect!");
  });

  return (
    <div className="app">
      <div className="app-counter">
        <div>CountA: {countA}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountA(countA + 1)}>CountA+</button>
      </div>
    </div>
  );
};

export default App;

第2引数を利用するとuseEffectの実行条件を設定できる

useEffectは第2引数に依存配列と呼ばれる配列を設定できます。
第2引数を設定することで、依存配列が変更した時のみuseEffectが実行されます。

第2引数をうまく活用することで、不必要なuseEffectの実行を抑えられるため、パフォーマンスの向上が期待できます。
しかし、不適切な依存配列を設定すると意図しない挙動となり、バグの原因にもつながるため注意が必要です。

useEffectの第2引数の利用パターン

useEffectの第2引数の利用方法をパターン別に紹介します。

マウント・アンマウント時のみuseEffectを実行する場合

コンポーネントを表示する際に必要なデータをAPI経由で取得するケースなどがこの例に該当します。
コンポーネントのマウント・アンマウントの時のみuseEffectを実行したい場合は第2引数に空配列を設定します。
useEffectの2引数を空配列にすることでマウント時には副作用が、アンマウント時にはクリーンアップ関数が1度だけ実行されます。1

先ほど紹介したコンポーネントのuseEffectの第2引数に[]を設定してみます。
すると、コンポーネントのマウント時に一度useEffectが実行されるのみで、それ以降はクリックをしてもuseEffectは実行されません。

import React, { useEffect, useState } from "react";
import "./App.css";

const App: React.FC = () => {
  const [countA, setCountA] = useState(0);

  // Before: ボタンがクリックされるたびに呼び出される
  // After: マウント時に実行される。その後ボタンをクリックしても実行されない
  useEffect(() => {
    console.log("useEffect!");
- });
+ }, []);

  return (
    <div className="app">
      <div className="app-counter">
        <div>CountA: {countA}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountA(countA + 1)}>CountA+</button>
      </div>
    </div>
  );
};

export default App;

一度実行するだけでよいuseEffectの第2引数は[]を設定しましょう。

コンポーネントのPropsやStateをuseEffectで利用する場合

useEffect内でコンポーネントのStateやPropsを利用している場合、State・Propsに依存していると言えます。
useEffectがState・Propsに依存している場合は第2引数に当該State・Propsを指定します。

例として、CountA・CountBというStateを持つコンポーネントを用意しました。
CountAのボタンを押したらCountA、CountBのボタンを押したらCountBに関するログを表示させる場合のuseEffectは以下のようになります。

countAボタンのクリックによってcountAのStateが更新されると、countAを依存配列にもつuseEffectが実行されます。その結果、countAに関するログが表示されます。
一方countAボタンのクリックによってcountBは変更しないため、countBを依存配列にもつuseEffectは実行されません。
countBがクリックされた場合はその逆です。

import React, { useEffect, useState } from "react";
import "./App.css";

const App: React.FC = () => {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);

  // countAボタンがクリックされたら実行される
  useEffect(() => {
    console.log(`countA is clicked ${countA} times!`);
  }, [countA]);

  // countBボタンがクリックされたら実行される
  useEffect(() => {
    console.log(`countB is clicked ${countB} times!`);
  }, [countB]);

  return (
    <div className="app">
      <div className="app-counter">
        <div>CountA: {countA}</div>
        <div>CountB: {countB}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountA(countA + 1)}>CountA+</button>
        <button onClick={() => setCountB(countB + 1)}>CountB+</button>
      </div>
    </div>
  );
};

export default App;

useEffect内で作成された関数がStateやPropsを利用している場合も同様に、第2引数に依存しているState・Propsを設定します。
具体的には以下のようになります。

import React, { useEffect, useState } from "react";
import "./App.css";

const App: React.FC = () => {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);

  // countAボタンがクリックされたら実行される
  useEffect(() => {
     console.log(`countA is clicked ${countA} times!`);
     const countLog = (count: number) =>
       console.log(`countA is clicked ${count} times!`);
     countLog(countA);
  }, [countA]); // countLogではなく、countLogで利用されているcountAを依存配列に設定する

  // countBボタンがクリックされたら実行される
  useEffect(() => {
    console.log(`countB is clicked ${countB} times!`);
  }, [countB]);

  return (
    <div className="app">
      <div className="app-counter">
        <div>CountA: {countA}</div>
        <div>CountB: {countB}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountA(countA + 1)}>CountA+</button>
        <button onClick={() => setCountB(countB + 1)}>CountB+</button>
      </div>
    </div>
  );
};

export default App;

eslint-plugin-react-hooksを導入すると、exhaustive-depsというESLintのルールが依存配列の漏れを指摘してくれるので便利です。2

create-react-appではreact-scriptsによってeslint-plugin-react-hooksがすでにインストールされています。

なお、setState関数・dispatch関数はReactの再レンダリング間で同一性が保たれています。つまり、再レンダリングによって関数の変化が起こりません。
ですので、第2引数の依存配列に含める必要はありません。3 4

補足: Stateの更新をuseEffectで実行している場合は無限ループに注意

useEffectでStateの更新をしている場合、無限ループになる可能性があるので注意が必要です。

無限ループの流れは以下の通りです。

useEffectの無限ループの流れ
  1. コンポーネントがレンダリングされる
  2. useEffectが実行される
  3. useEffectでStateが更新される
  4. (Stateが更新されたので)コンポーネントがレンダリングされる
  5. (以下ループ)

たとえば、以下のコンポーネントは無限ループによってカウントが増加し続けます。

import React, { useEffect, useState } from "react";
import "./App.css";

const App: React.FC = () => {
  const [countA, setCountA] = useState(0);

  // 無限ループ!!!
  useEffect(() => {
    setCountA(countA + 1)
  });

  return (
    <div className="app">
      <div className="app-counter">
        <div>CountA: {countA}</div>
      </div>
      <div className="app-button">
        <button onClick={() => {}}>CountA+</button>
      </div>
    </div>
  );
};

export default App;

useEffectとレンダリングが繰り返されないようにする条件(依存配列)を第2引数に設定することで、無限ループを防ぐことができます。

コンポーネントで定義された関数をuseEffectで利用する場合

関数コンポーネント内で定義されたアロー関数はレンダリングするたびに再作成されます。
そのため、useEffectでコンポーネント内の関数を利用する際は依存配列の設定方法に注意を払う必要があります。

ダメな例: コンポーネントで定義された関数を依存配列に設定する

先ほど紹介したuseEffectをコンポーネントの関数を利用して実装してみます。
countALog()の結果はCountAが変化しない限り変更されません。ですので、素直に考えるとcountALogを依存配列に設定すればCountAの変化に応じて実行されるuseEffectが作成できそうです。

具体的なコードは以下になります。

import React, { useEffect, useState } from "react";
import "./App.css";

const App: React.FC = () => {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);

  const countALog = () => console.log(`countA is clicked ${countA} times!`);

  // countAボタンだけでなく、countBボタンがクリックされても実行される
 // (本当はcountAボタンがクリックされた時のみ実行されるようにしたい)
  useEffect(() => {
    countALog();
  }, [countALog]);

  // countBボタンがクリックされたら実行される
  useEffect(() => {
    console.log(`countB is clicked ${countB} times!`);
  }, [countB]);

  return (
    <div className="app">
      <div className="app-counter">
        <div>CountA: {countA}</div>
        <div>CountB: {countB}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountA(countA + 1)}>CountA+</button>
        <button onClick={() => setCountB(countB + 1)}>CountB+</button>
      </div>
    </div>
  );
};

export default App;

しかし、実際にはCountAだけでなく、CountBのクリック時にもcountALogを依存配列にもつuseEffectが実行されてしまいます。

countAが変わらない限りcountALog()の結果は変わりませんが、レンダリングによってcountALog()は再作成されます。
その結果、依存配列が変更したと見なされ、意図した制御ができなくなっています。

方法1: 関数ではなく、依存しているState・Propsを依存配列に設定する

コンポーネントの関数を利用する場合も、依存しているState・Propsに注目をして第2引数を設定します。
以下のようにするとCountAのクリックの時のみ、CountAに関するuseEffectが実行されます。

import React, { useEffect, useState } from "react";
import "./App.css";

const App: React.FC = () => {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);

  const countALog = () => console.log(`countA is clicked ${countA} times!`);

  // Before: countAボタンだけでなく、countBボタンがクリックされても実行される
  // After: countAボタンがクリックされたら実行される
  useEffect(() => {
    countALog();
- }, [countALog]);
+ }, [countA]);

  useEffect(() => {
    console.log(`countB is clicked ${countB} times!`);
  }, [countB]);

  return (
    <div className="app">
      <div className="app-counter">
        <div>CountA: {countA}</div>
        <div>CountB: {countB}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountA(countA + 1)}>CountA+</button>
        <button onClick={() => setCountB(countB + 1)}>CountB+</button>
      </div>
    </div>
  );
};

export default App;

方法2: useCallbackを利用した関数を依存配列にセットする

useCallbackは関数をメモ化するフックです。
useCallbackを利用することで関数がキャッシュされるため、コンポーネントの関数を依存配列に設定できます。

import React, { useEffect, useState, useCallback } from "react";
import "./App.css";

const App: React.FC = () => {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);

- const countALog = () => console.log(`countA is clicked ${countA} times!`);
+ const countALog = useCallback(() => {
+   console.log(`countA is clicked ${countA} times!`);
+ }, [countA]);

  // countAボタンがクリックされたら実行される
  useEffect(() => {
    countALog();
  }, [countALog]);

  useEffect(() => {
    console.log(`countB is clicked ${countB} times!`);
  }, [countB]);

  return (
    <div className="app">
      <div className="app-counter">
        <div>CountA: {countA}</div>
        <div>CountB: {countB}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountA(countA + 1)}>CountA+</button>
        <button onClick={() => setCountB(countB + 1)}>CountB+</button>
      </div>
    </div>
  );
};

export default App;

補足: StateやPropsに依存しない関数にすればコンポーネントが簡潔になる

StateやPropsを利用する関数はコンポーネント内に定義する必要があります。
しかし、StateやPropsに依存しない形で実装すればコンポーネント外で関数の定義ができるため、コンポーネントのコードが簡潔になります。

コンポーネントの外で定義された関数はレンダリングによって再作成されることはありません。

今回の例でいうと、以下のようにリファクタリングできます。

// メソッドを外だしすることでコンポーネント内のコードが簡潔になる
+ const countLog = (count: number, type: string) =>
+  console.log(`count${type} is clicked ${count} times!`);

const App: React.FC = () => {
  const [countA, setCountA] = useState(0);
  const [countB, setCountB] = useState(0);

- const countALog = () => console.log(`countA is clicked ${countA} times!`);

  // countAボタンがクリックされたら実行される
  useEffect(() => {
-   countALog();
+   countLog(countA, "A");
  }, [countA]);

  useEffect(() => {
    console.log(`countB is clicked ${countB} times!`);
  }, [countB]);

  return (
    <div className="app">
      <div className="app-counter">
        <div>CountA: {countA}</div>
        <div>CountB: {countB}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountA(countA + 1)}>CountA+</button>
        <button onClick={() => setCountB(countB + 1)}>CountB+</button>
      </div>
    </div>
  );
};

export default App;

補足: useEffectでしか利用しない関数はコンポーネントではなくuseEffect内に定義する

コンポーネントで定義された関数をuseEffectで利用するケースについて紹介をしました。
しかし、そもそもuseEffectでしか利用しない関数なのであればuseEffect内に関数を定義したほうがよいです。 5

useEffect内に関数を移動することでuseEffectの依存関係が明確になります。
ですので、コンポーネントで定義されている関数をuseEffectで利用する場合は、そもそもuseEffectに関数を移動できないか検討するとよいでしょう。

たとえば、『クリックのタイミングでログを出力する』という関数がuseEffectでしか利用されていないのであれば、以下のように実装するとよいです。

import React, { useEffect, useState } from "react";
import "./App.css";

const App: React.FC = () => {
  const [countA, setCountA] = useState(0);

- const countALog = () => console.log(`countA is clicked ${countA} times!`);

  // countAボタンがクリックされたら実行される
  useEffect(() => {
-   countALog();

    // countALogのスコープがuseEffect内におさまるので依存関係がわかりやすくなる。
+   const countALog = () =>
+     console.log(`countA is clicked ${countA} times!`);
    countALog();
  }, [countA]);

  return (
    <div className="app">
      <div className="app-counter">
        <div>CountA: {countA}</div>
      </div>
      <div className="app-button">
        <button onClick={() => setCountA(countA + 1)}>CountA+</button>
      </div>
    </div>
  );
};

export default App;

まとめ

useEffectの第2引数のまとめ
  • マウント・アンマウント時のみ実行するuseEffectの第2引数は空配列[]にする
  • useEffectがStateやPropsに依存する場合は第2引数に依存対象を設定する
  • useEffectでStateの更新をする場合は無限ループに注意する
  • 関数コンポーネント内に定義された関数はレンダリングで再作成されるので依存配列で利用する場合は注意する
  • useEffectでのみ利用される関数はuseEffectに定義する

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!