🍉

useEffectの中でsetStateを使うときはアンチパターンを疑おう

2024/08/16に公開

結論

useEffectの中でsetStateを使いたくなったときは、まずは他の方法がないかを確認しよう!

概要

React で開発を行う際、(useState の)setStateと、useEffectはほぼ必ず使います。

しかし、これらを適切に組み合わせないと、コードの複雑さが増し、予期しないバグが発生する可能性があります。

特に、useEffectの中でsetStateを使用することはアンチパターンに繋がる目印になりやすいです。今回の記事では、コード例を紹介しながら図や動画を交えて解説していきます。

useEffectの中でsetStateを使うアンチパターン

1.無限ループの発生

useEffectの依存配列にsetStateで管理する状態を指定すると、その状態が更新されるたびにuseEffectが再度実行され、再度setStateが更新されるという無限ループに陥る可能性があります。

// state が変更される → useEffect が再度実行される → stateが変更される…(以下ループ)
useEffect(() => {
  setState((prevState) => prevState + 1);
}, [state]);

シークエンス図で表すと次のようになります。

無限ループに陥ると、UIがフリーズしたり、アプリがクラッシュします。

https://qiita.com/acronhub/items/3a70f75af296dae81be2

こちらの記事でも紹介されているように、Warning: Maximum update depth exceeded.というエラーメッセージがコンソールに出力されます。比較的気づきやすいですが、注意が必要です。

2. 副作用が意図しないタイミングで発生する

さて、ここからが本題です。

useEffectはコンポーネントのレンダリング後に実行されますが、状態の更新がその後に行われるため、特定の条件を満たす前に副作用が実行されてしまうことがあります。

問題のあるコード例

副作用が意図しないタイミングで発生する問題を引き起こす例として、isEvenが古い状態で使用されることで、ユーザーに誤解を与える状況を想像してみましょう。

例えば、次のようなケースを考えます。このコンポーネントでは、countが偶数か奇数かによって、どのログを出力するかを決定しています。

export default function SimpleCounter() {
  const [count, setCount] = useState(0);
  const [isEven, setIsEven] = useState(true);

  useEffect(() => {
    setIsEven(count % 2 === 0);
  }, [count]);

  const performAction = () => {
    if (isEven) {
      console.log("Action performed because count is even");
    } else {
      console.log("Action not allowed because count is odd");
    }
  };

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

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleButtonClick}>Increment Count</button>
      <button onClick={performAction}>Perform Action</button>
    </div>
  );
}

このコードでは、performAction関数がisEvenの状態に依存して実行されます。

ユーザーが「Increment Count」ボタンをクリックすると、countが増加し、useEffectがトリガーされてisEvenの状態が更新されます。

しかし、useEffectはレンダリング後に実行されるため、performActionを実行するボタンが押された際にはisEvenの値がまだ更新されていない可能性があります。

文章でサラッと読んだだけでは、何が問題か分かりづらいかもしれません。

そこで……動画を用意しました! 現状のコードについて、動画で確認してみましょう。

https://youtu.be/Ij3sOAL-NlE

このように、「Increment Count」ボタンを押したのに、ボタン押下前の値を参照したログを出力してしまっています。

改善されたコード例

このような問題を避けるためには、isEvenの状態に直接依存する形で計算する方法が考えられます。これにより、状態の一貫性が保たれ、意図しない動作を防ぐことができます。

具体的にどうすれば良いでしょうか?

解決方法は簡単です。React の公式ドキュメントでも詳しく説明されているように、依存関係を減らしてシンプルにすることができます。

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

  // 今回はuseEffectを使わずに実装できます
  const isEven = count % 2 === 0;

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

  const performAction = () => {
    if (isEven) {
      console.log("Action performed because count is even");
    } else {
      console.log("Action not allowed because count is odd");
    }
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleButtonClick}>Increment Count</button>
      <button onClick={performAction}>Perform Action</button>
    </div>
  );
}

この方法では、isEvenの状態は countに直接依存して計算される ため、レンダリングのタイミングに関わらず、常に最新の状態が保持されます。 これにより、副作用が意図しないタイミングで発生することを防ぐことができます。

そのため、たとえばhandleButtonClick内に重い処理がある場合でも、isEvenの値は常に最新の状態であるため、正しい結果が表示されます。

改善後のコードについても、動画を見てみましょう。

https://youtu.be/DBbt8uvDJKI

このように、ボタン押下後の値を参照して、ログを出力しています。


実務ではコードがもっと複雑で、どれが遅延を生み出しているのか分からない場合もあるでしょう。heavyCalculationのように、分かりやすい関数がないかもしれません。

また、外部ライブラリが遅延を生み出している可能性も考慮しなければなりません。

そのため、問題が生じる前の段階から、Reactとして良いコンポーネント設計を保ち続けることが重要になってきます。

改善前と改善後のコードの全容

Before

コードの全容を用意しました。実際にこのコードを使ってブラウザ上で試してみると分かりやすいと思います。

import { useState, useEffect } from "react";

export default function SimpleCounter() {
  const [count, setCount] = useState(0);
  const [isEven, setIsEven] = useState(true);

  useEffect(() => {
    setIsEven(count % 2 === 0);
  }, [count]);

  const performAction = () => {
    if (isEven) {
      console.log("Action performed because count is even");
    } else {
      console.log("Action not allowed because count is odd");
    }
  };

  const handleButtonClick = () => {
    setCount(count + 1);

    const heavyCalculation = () => {
      let total = 0;
      for (let i = 0; i < 1e9; i++) {
        total += i;
      }
      return total;
    };
    heavyCalculation();
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleButtonClick}>Increment Count</button>
      <button onClick={performAction}>Perform Action</button>
    </div>
  );
}

After

import { useState } from "react";

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

  const isEven = count % 2 === 0;

  const handleButtonClick = () => {
    setCount(count + 1);

    const heavyCalculation = () => {
      let total = 0;
      for (let i = 0; i < 1e9; i++) {
        total += i;
      }
      return total;
    };
    heavyCalculation();
  };

  const performAction = () => {
    if (isEven) {
      console.log("Action performed because count is even");
    } else {
      console.log("Action not allowed because count is odd");
    }
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleButtonClick}>Increment Count</button>
      <button onClick={performAction}>Perform Action</button>
    </div>
  );
}

useEffect の設計のコツ

ここからは自分流のコツになります。

最初からuseEffectをスッと削除できたら楽ですが、実務で書くようなコードは複雑で、そう簡単にはいきません。最初の段階ではどうリファクタリングしていったら良いか分からないことも多いです。

そのため、自分はこのようなフローチャートを作っています。

  1. useEffect の中で setState を使っていないか確認する
  2. 無限ループになっていないか確認する
  3. 副作用が意図しないタイミングで発生しないか確認する
  4. useEffect を分割する
  5. useEffect を消せないか検討する

それぞれの手順について、順番に説明していきます。

Before

例えば次のようなコードがあるとします。

これは、1 秒ごとにcountを加算しつつ、count5よりも大きくなったら特定のテキストを表示するコードです(仕様としてcountの加算は自動的に始まるものとします)。

useEffect(() => {
  const id = setInterval(() => {
    setCount((prevCount) => prevCount + 1);
  }, 1000);

  if (count > 5) {
    setText("Count is greater than 5");
  }

  return () => clearInterval(id);
}, [count]);

まず、useEffectの中でsetStateを使っていないか確認します。あ、使っていました。あまり良くなさそうな予感がします。

更に、useEffectの依存配列にcountがあるのにsetCountを使っています。これは無限ループになりそうです。

また、スコープ内でsetCountcount > 5(Stateを利用した条件)が両方使われていて、副作用が意図しないタイミングで発生する可能性もあります。

上記の手順を使うことで、「嫌な予感」を言語化することができます。

After?

依存関係も複雑になっていそうです。

これを改善するために、useEffectをロジックごとにぶった切りましょう。

// Countを1秒ごとに増加させるロジック
useEffect(() => {
  const id = setInterval(() => {
    setCount((prevCount) => prevCount + 1);
  }, 1000);

  return () => clearInterval(id);
}, []);

// Countが5を超えたらテキストを更新するロジック
useEffect(() => {
  if (count > 5) {
    setText("Count is greater than 5");
  }
}, [count]);

useEffectを分割することで、それぞれのロジックが明らかになり、だいぶ見通しが良くなりました。

分割するときのコツですが、意味的な単位でまとまっているかを考慮すると良いでしょう。今回は、「Countの増加そのもの」と「特定の条件下でのテキストの更新」という違いがあります。

これで終わることもありますが、更にリファクタリングできる場合もあります。

After

そして今回は更にリファクタリングできます。そう、useEffectそのもののを削除できるパターンです。

// Countを1秒ごとに増加させるロジック
useEffect(() => {
  const id = setInterval(() => {
    setCount((prevCount) => prevCount + 1);
  }, 1000);

  return () => clearInterval(id);
}, []);

// こちらのuseEffectを削除
// textの状態はcountに基づいて計算
const text = count > 5 ? "Count is greater than 5" : "";

textの状態をcountに基づいて直接計算できるようになりました!

「Countを1秒ごとに増加させるロジック」については、useEffect の中で setState を使っています。今回は、自動的にカウントアップが始まる仕様なので、このままで問題ありません。バッチリです。👌


コードが複雑な場合、最初の Before から最後の After までを一気に導き出すのは難しいかもしれません。

そのため、useEffectを分割できないかを検討するステップを踏んでからuseEffectそのものを使わなくて済むかを検討すると、スムーズにコードを改善することができます。

まとめ

useEffectの中でsetStateを使用することは一見便利に思えるかもしれませんが、無限ループの発生や、複雑な依存関係の管理など、さまざまなリスクを伴います。

もしそのようなコードがあったら、他の書き方ができないかを一通り検討すると、良いコンポーネント設計へとリファクタリングできる確率が高くなります。

useEffectの中でsetStateを使うことは問題ではありませんが、アンチパターンになっていないかを疑うことで、予期せぬ挙動を防ぐことができるのでオススメです。

まとめのまとめ

useEffectの中でsetStateを使いたくなったときは、まずは他の方法がないかを検討しよう!

もし、それしか方法がないときは、無限ループや意図しないタイミングの副作用がないかを注意しよう!

参考文献

Discussion