useEffectの中でsetStateを使うときはアンチパターンを疑おう
結論
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がフリーズしたり、アプリがクラッシュします。
こちらの記事でも紹介されているように、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
の値がまだ更新されていない可能性があります。
文章でサラッと読んだだけでは、何が問題か分かりづらいかもしれません。
そこで……動画を用意しました! 現状のコードについて、動画で確認してみましょう。
このように、「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
の値は常に最新の状態であるため、正しい結果が表示されます。
改善後のコードについても、動画を見てみましょう。
このように、ボタン押下後の値を参照して、ログを出力しています。
実務ではコードがもっと複雑で、どれが遅延を生み出しているのか分からない場合もあるでしょう。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
をスッと削除できたら楽ですが、実務で書くようなコードは複雑で、そう簡単にはいきません。最初の段階ではどうリファクタリングしていったら良いか分からないことも多いです。
そのため、自分はこのようなフローチャートを作っています。
- useEffect の中で setState を使っていないか確認する
- 無限ループになっていないか確認する
- 副作用が意図しないタイミングで発生しないか確認する
- useEffect を分割する
- useEffect を消せないか検討する
それぞれの手順について、順番に説明していきます。
Before
例えば次のようなコードがあるとします。
これは、1 秒ごとにcount
を加算しつつ、count
が5
よりも大きくなったら特定のテキストを表示するコードです(仕様として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
を使っています。これは無限ループになりそうです。
また、スコープ内でsetCount
とcount > 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
を使いたくなったときは、まずは他の方法がないかを検討しよう!
もし、それしか方法がないときは、無限ループや意図しないタイミングの副作用がないかを注意しよう!
参考文献
- React では state の更新が即時反映されない場合がある - Breathnote
- React の useEffect とクリーンアップ関数の実行タイミングがこれだけで分かる
- javascript - Can I set state inside a useEffect hook - Stack Overflow
-
https://x.com/sub_827/status/1821545589856989537
-
sub_827
さんのこちらのツイートは、useEffectの中でsetStateが現れる状況について、とても端的にまとめていて慧眼でした。
-
-
そのエフェクトは不要かも – React
- Reactの公式ドキュメントは内容が充実していて本当に素晴らしいのでオススメです。
Discussion