↔️

useEffectは最終手段!5つのアンチパターンから学ぶuseEffectの副作用

に公開

はじめに

「useEffect は副作用を扱うためのもの」「useEffect は最終手段」

React を書いていると、こういった話を耳にすることがあると思います。React 公式ドキュメントでも、You Might Not Need an Effectというページで「多くの場合 useEffect は不要」と明言されています。

そこで今回は、具体的にどんな問題が起きるのか、ちゃんと確かめてみようと思います!

本記事では「なんとなく良くないらしい」で終わらせず、useEffect を誤用したときに何が起きるのかを、Before/After のレンダリング回数を比較しながら体感していきます。

そもそも「副作用」とは?

React における**副作用(Side Effect)**とは、「レンダリング中に行うべきではない処理」のことです。

公式ドキュメントによると、React コンポーネントは純粋関数であるべきとされています。

// 純粋関数:同じ入力には常に同じ出力を返す
function Double({ number }) {
  return <span>{number * 2}</span>;
}

純粋関数の特徴:

  • 自分の仕事に集中する:呼び出される前に存在していたオブジェクトや変数を変更しない
  • 同じ入力には同じ出力:同じ props には常に同じ JSX を返す

では、副作用とは何か?

// 副作用の例
- DOMの直接操作
- データのフェッチ(API呼び出し)
- タイマーのセット
- ローカルストレージへのアクセス
- イベントリスナーの登録

これらは「外部システムとの同期」が必要な処理であり、useEffect の本来の用途です。

しかし、多くの場合、useEffect を使わなくても解決できます

5 つのアンチパターン

パターン 1:派生状態の計算

🔗 デモを見る

❌ Before(悪い例)

state から計算できる値を、useEffect で別の state に保存するパターンです。

const [category, setCategory] = useState("all");
const [filteredProducts, setFilteredProducts] = useState(products);
const [totalPrice, setTotalPrice] = useState(0);

// ❌ useEffectで派生状態を計算
useEffect(() => {
  const filtered =
    category === "all"
      ? products
      : products.filter((p) => p.category === category);
  setFilteredProducts(filtered);
}, [category]);

// ❌ さらに別のuseEffectで合計を計算
useEffect(() => {
  const total = filteredProducts.reduce((sum, p) => sum + p.price, 0);
  setTotalPrice(total);
}, [filteredProducts]);

問題点:

  • カテゴリ変更で3 回レンダリングされる(state 変更 → effect → state 変更 → effect → state 変更)
  • 一瞬古いデータが表示される可能性がある
  • コードが複雑になる

✅ After(良い例)

const [category, setCategory] = useState("all");

// ✅ useMemoで派生値を計算
const filteredProducts = useMemo(() => {
  return category === "all"
    ? products
    : products.filter((p) => p.category === category);
}, [category]);

// ✅ 合計も同様に
const totalPrice = useMemo(() => {
  return filteredProducts.reduce((sum, p) => sum + p.price, 0);
}, [filteredProducts]);

改善点:

  • カテゴリ変更で1 回だけレンダリング
  • 常に最新の計算結果が表示される
  • useEffect なし!

パターン 2:props で state リセット

🔗 デモを見る

❌ Before(悪い例)

親からの props が変わったとき、useEffect で state をリセットするパターンです。

function ProfileForm({ user }) {
  const [name, setName] = useState(user.name);
  const [email, setEmail] = useState(user.email);

  // ❌ propsが変わったらuseEffectでstateをリセット
  useEffect(() => {
    setName(user.name);
    setEmail(user.email);
  }, [user]);

  return (
    <form>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
    </form>
  );
}

// 親コンポーネント
<ProfileForm user={selectedUser} />;

問題点:

  • ユーザー切替時に一瞬古いデータが表示される
  • 余分なレンダリングが発生

✅ After(良い例)

function ProfileForm({ user }) {
  // useEffectは不要!
  const [name, setName] = useState(user.name);
  const [email, setEmail] = useState(user.email);

  return (
    <form>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
    </form>
  );
}

// ✅ 親コンポーネント: key属性を追加
<ProfileForm key={selectedUser.id} user={selectedUser} />;

改善点:

  • keyが変わると React がコンポーネントを破棄 → 再作成
  • state は自動的に初期値にリセット
  • 古いデータが表示されることがない

パターン 3:イベントハンドラで行うべき処理

🔗 デモを見る

❌ Before(悪い例)

ボタンクリック等のイベント処理を、フラグ + useEffect で行うパターンです。

const [shouldSubmit, setShouldSubmit] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);

// ❌ フラグを監視して処理を実行
useEffect(() => {
  if (!shouldSubmit) return;

  const submitOrder = async () => {
    setIsSubmitting(true);
    await submitToServer();
    setIsSubmitting(false);
    setShouldSubmit(false); // フラグをリセット(忘れがち!)
  };

  submitOrder();
}, [shouldSubmit]);

const handleSubmit = () => {
  // 直接処理せず、フラグを立てるだけ
  setShouldSubmit(true);
};

問題点:

  • フラグ管理が複雑
  • フラグのリセット忘れでバグが発生しやすい
  • 処理の流れが追いにくい

✅ After(良い例)

const [isSubmitting, setIsSubmitting] = useState(false);

// ✅ イベントハンドラで直接処理
const handleSubmit = async () => {
  setIsSubmitting(true);
  await submitToServer();
  setIsSubmitting(false);
};

改善点:

  • shouldSubmitフラグ不要
  • 処理の流れが明確
  • useEffect なし!

パターン 4:データフェッチ

🔗 デモを見る

❌ Before(悪い例)

クリーンアップなしでデータをフェッチするパターンです。

const [postId, setPostId] = useState(1);
const [post, setPost] = useState(null);

useEffect(() => {
  setLoading(true);

  // ❌ クリーンアップがない
  fetchPost(postId).then((data) => {
    setPost(data);
    setLoading(false);
  });

  // クリーンアップ関数がない!
}, [postId]);

問題点:

  • 競合状態(Race Condition) が発生する
  • 素早く ID を切り替えると、遅いリクエストの結果が後から表示される
  • アンマウント後に setState が呼ばれる可能性がある

✅ After(良い例)

const [postId, setPostId] = useState(1);
const [post, setPost] = useState(null);

useEffect(() => {
  const abortController = new AbortController();

  setLoading(true);

  fetchPost(postId, abortController.signal)
    .then((data) => {
      setPost(data);
      setLoading(false);
    })
    .catch((err) => {
      if (err.name === "AbortError") return; // キャンセルは無視
      setError(err.message);
    });

  // ✅ クリーンアップ: 古いリクエストをキャンセル
  return () => abortController.abort();
}, [postId]);

改善点:

  • AbortControllerで古いリクエストをキャンセル
  • 競合状態を防止
  • 常に最新のデータのみ表示

パターン 5:チェーンされた useEffect

🔗 デモを見る

❌ Before(悪い例)

複数の useEffect が連鎖的に実行されるパターンです。

const [country, setCountry] = useState("");
const [cities, setCities] = useState([]);
const [selectedCity, setSelectedCity] = useState(null);
const [weather, setWeather] = useState(null);

// useEffect #1: 国が変わったら都市を取得
useEffect(() => {
  fetchCities(country).then((data) => {
    setCities(data);
    setSelectedCity(data[0]?.id); // これがuseEffect #2をトリガー
  });
}, [country]);

// useEffect #2: 都市が選択されたら天気を取得
useEffect(() => {
  if (!selectedCity) return;
  fetchWeather(selectedCity).then(setWeather);
}, [selectedCity]);

問題点:

  • country変更 → render → effect#1 → state 更新 → render → effect#2 → state 更新 → render
  • 連鎖的なレンダリングが発生
  • データフローが追いにくい

✅ After(良い例)

const [country, setCountry] = useState("");
const [data, setData] = useState({
  cities: [],
  selectedCity: null,
  weather: null,
});

// ✅ 一つのuseEffectでまとめて処理
useEffect(() => {
  if (!country) return;
  let cancelled = false;

  const fetchData = async () => {
    const cities = await fetchCities(country);
    if (cancelled) return;

    const weather = await fetchWeather(cities[0]?.id);
    if (cancelled) return;

    // 一度にすべて更新
    setData({ cities, selectedCity: cities[0]?.id, weather });
  };

  fetchData();
  return () => {
    cancelled = true;
  };
}, [country]);

改善点:

  • データ取得を一箇所で管理
  • 中間状態の更新による余分なレンダリングを削減
  • データフローが明確

まとめ:useEffect を使う前に確認すること

やりたいこと useEffect の代わりに
state から計算できる値を求めたい useMemoまたは直接計算
props が変わったら state をリセットしたい key属性を使う
ボタンクリック等で処理を実行したい イベントハンドラで直接実行
データをフェッチしたい TanStack Query / SWR を検討
複数の useEffect が連鎖している 一つにまとめる or イベントハンドラに移動

useEffect の本来の用途は、「外部システムとの同期」です。

  • DOM API の操作
  • サードパーティライブラリとの連携
  • WebSocket の接続
  • イベントリスナーの登録/解除

これら以外の用途で useEffect を使おうとしている場合は、
一度立ち止まって「本当に useEffect が必要か?」を考えてみるのがよさそうですね。

デモ・ソースコード

実際に動かして違いを確認できるデモサイトを用意しました。

🔗 デモ: https://tamoco-mocomoco.github.io/react-use-effect-anti-pattern/
📦 GitHub: https://github.com/tamoco-mocomoco/react-use-effect-anti-pattern

参考資料

Discussion