Tyotto good!

【React Hooks】useEffectのよくある間違い4選とそれらを回避する方法

Posted: February 22, 2021

React HooksのuseEffectにおいて、よく間違えやすい注意点をここでは解説していきたいと思います。
useEffectの基礎的な内容を学びたいかたはこちらからご覧ください!

useEffectに渡すコールバック関数をPromiseにしてはいけない

useEffect内で非同期処理を行うために、第一引数のコールバック関数にPromiseを渡してしまうことがあるかもしれません。例えば以下のような例が考えられます。

import React, { useEffect } from "react";

const UseEffectAnti1: React.FC<{ query: string }> = ({ query }) => {
  useEffect(async () => {
    const response = await fetch(
      `https://www.googleapis.com/books/v1/volumes?q=${query}`
    );
    console.log(response);
  }, [query]);

  return <div>query: {query}</div>;
};

export default UseEffectAnti1;

上記の例では、propsにAPI検索用のクエリを渡しており、それが変更される度にリクエストを行うようになっています。
この問題点としては、非同期処理を行いたいがために、useEffectの引数に非同期関数を渡してしまっている点です。そのせいでPromise型が戻り値として設定されるため、エラーとなります。(useEffect内のコールバック関数の戻り値はクリーンアップ関数を設定しないといけない)

解決策

そのため、正しくはuseEffectのコールバック関数の内部に非同期関数を定義し、呼び出すようにしてあげれば大丈夫です。以下のように修正しましょう。

import React, { useEffect } from "react";

const UseEffectAnti1: React.FC<{ query: string }> = ({ query }) => {
  useEffect(() => {
    async function fetchEmployees() {
      const response = await fetch(
        `https://www.googleapis.com/books/v1/volumes?q=${query}`
      );
      console.log(response);
    }
    fetchEmployees();
  }, [query]);

  return <div>query: {query}</div>;
};

export default UseEffectAnti1;

useEffect内部で毎秒stateを更新することができない(ライフサイクルモデルとの混合)

以下は、毎秒countが1ずつ増え、表示されると想定しているが、実際countは1のままになってしまうといった例です。

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

const UseEffectAnti2: React.FC = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <div>{count}</div>;
};

export default UseEffectAnti2;

なぜuseEffectsetInterval内部でcountは更新されないのでしょうか。原因はuseEffectの第二引数に[]を指定しているためです。
関数コンポーネントは、レンダリングされる毎にそれぞれuseEffectをもっており(props, stateなども同様です)、特定のレンダリング時のstateを参照することになります。
上記の例だと、初回のレンダリング時のcountを参照することになり、count=0 であることがわかります。そして、useEffectの第二引数に依存する値を入れていないので、それ以降countが変更されてもuseEffectのコールバック関数が実行されることはありません。
つまり、setCount(0 + 1)を毎秒繰り返すことになります。

解決策

  • 依存する値を減らす

シンプルな解決策としては、第二引数の依存配列に必ず依存してる値をいれることです。今回の場合countを入れます。

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

const UseEffectAnti2: React.FC = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, [count]);

  return <div>{count}</div>;
};

export default UseEffectAnti2;

以下は毎レンダリング時のイメージです。

// 初回レンダリング時
const UseEffectAnti2: React.FC = () => {
	...
  useEffect(() => {
    const id = setInterval(() => {
			// 
      setCount(0 + 1);
    }, 1000);
    return () => clearInterval(id);
  }, [0]);
	...
};

// 2回目のレンダリング時 count = 1に変化
const UseEffectAnti2: React.FC = () => {
	// count = 
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(1 + 1);
    }, 1000);
    return () => clearInterval(id);
  }, [1]);
	...
};

useEffectがcountの変化をキャプチャできていることがわかります。

  • lint ruleの導入

eslint-plugin-react-hooks@nextを導入することで依存している値を入れていない時エラーが表示されるため、事前に防ぐことが可能です。

  • 依存する値を減らす

前の state に基づいて state をアップデートしたい場合は、setState の関数型の更新を使うことでcountを参照することがなくなり、依存配列に書く必要がなくなります。

useEffect(() => {
    const id = setInterval(() => {
      setCount(c => c + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

useEffectが無限ループに陥ってしまう

次に、以下のようなコンポーネントを考えましょう。ぱっと見、問題なさそうなコードに見えます。

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

const UseEffectAnti3: React.FC = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    setCount(count + 1);
  });

  return (
    <>
      <h3>click count : {count}</h3>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </>
  );
};

export default UseEffectAnti3;

しかし、こちらのコンポートがレンダリングされるととんでもない速度でカウント数が増えてしまい、無限ループに陥ってしまいます。
問題は

useEffect(() => {
    setCount(count + 1);
});

にあります。useEffectのコールバック関数はコンポーネントがレンダリングされた後に実行されます。コールバック関数内でstateを更新しているため、実行直後にまたコンポーネントがレンダリングされ、useEffectのコールバック関数が実行され...と無限ループになるわけです。

解決策

  • 依存関係を見直す

シンプルな解決策としては、useEffectの依存関係を見直すことが挙げられます。先ほどの例ではレンダリングごとにuseEffectのコールバック関数を実行していたので、これを

useEffect(() => {
    setCount(count + 1);
}, []);

のように最初のレンダリングだけ実行されるように修正することで無限ループを回避することができます。

  • Refを使う

useRefを使い参照を更新することで、無限ループを回避することもできます。useEffect のコールバック関数内で参照値を更新してもコンポーネントは再レンダリングされないのでループは発生しなくなります。

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

const UseEffectSample: React.FC = () => {
  const [count, setCount] = useState(0);
  // good pattern 2
  const countRef = useRef(0);
  useEffect(() => {
    countRef.current++;
  });

  return (
    <>
      <h3>click count : {countRef.current}</h3>
      <button onClick={() => setCount(count + 1)}>Click me</button>
    </>
  );
};

オブジェクトを依存関係に設定した時UseEffectが無限ループに陥ってしまう

useEffectの第二引数の依存配列にオブジェクトを設定した場合、無限ループに陥る可能性があります。
以下のようなコンポーネントを考えてみましょう。

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

const UseEffectAnti5: React.FC = () => {
  const [input, setInput] = useState({ value: "", count: 0 });

  // anti pattern 5
  useEffect(() => {
    setInput((c) => ({ ...c, count: c.count + 1 }));
  }, [input]);

  return (
    <>
      <h3>modify count : {input.count}</h3>
      <input
        type="text"
        value={input.value}
        onChange={(e) => setInput((v) => ({ ...v, value: e.target.value }))}
      />
    </>
  );
};

原因は先ほどの例と同じように、

setInput((c) => ({ ...c, count: c.count + 1 }));

にあり、countを1増加させていて、この時新しいオブジェクトを作成しています。
そのため、オブジェクトinputを依存関係にもつuseEffectの副作用処理はまた呼び出されてしまい、無限ループへとなってしまいます。

解決策

  • 依存関係の変更

先ほどと同じ解決策です。オブジェクトのプロパティを依存配列に持たせましょう。

useEffect(() => {
    setInput((c) => ({ ...c, count: c.count + 1 }));
}, [input.value]);

こうすることで、入力値が変更された時のみ副作用の処理が呼ばれ、無限ループを回避することができます。

まとめ

以下、useEffectの注意点です。

  • useEffectに渡すコールバック関数をPromiseにしてはいけない
  • 関数コンポーネントはレンダリングされるごとにuseEffectをもつ
  • useEffectのコールバック関数内でstateを更新すると無限ループに陥る可能性がある

useEffectはReact Hooksの中でも特に使うシーンが多いと思いますが、しっかり動作を理解して使用しましょう!