🚨

あなたはuseEffectを正しく使えてますか?

に公開

はじめに

想定する読者

本記事はReact初級者から中級者向けの記事になります。
特に普段何となくでuseEffectを使用している方や使用を避けるべきアンチパターンを理解していない方、まだReactの公式ドキュメントのそのエフェクト不要かもを読んでない方などに少しでも知見になればと思います。

なぜ理解する必要があるのか

恐らく普段何となくでuseEffectを使用していてもそのコードの多くはひとまず期待する結果になっていると思います。「だったらいいじゃん。」と思考を停止していてはエンジニアとして成長がありませし、当然誤った使い方をするとパフォーマンスの低下やバグの温床になってしまいます。
エンジニアとして使用する技術に対して理解するドキュメントに沿った使い方をするというのは基本にして奥義であると思っています。
ある有名なフロントエンドエンジニアさんが言われていましたがこれまで色んなプロジェクトやエンジニアを見てきたがドキュメント通りにコードを書けているのは案外少ないしそれが出来るだけでかなり優秀との事です。
いきなり使用している全てのライブラリの公式ドキュメント読破して理解しろと言われても難しいですが使用頻度の高いものから順でも良いのでまずは公式ドキュメントを読んでみて、内容が難しく頭に入ってこない場合はこう言った日本語で書かれた記事をいくつか読んでみると良いかも知れません。
これらを繰り返していけば自分が書いたコードを完全に自身の管理下に置く事ができ思う様なアプリケーション開発が出来る様になると思っています。
まずはその第一歩としてuseEffectを支配し自身の管理下に置いてみます。

useEffectの役割を確認する

まずはuseEffectの役割から振り返ります。以下公式ドキュメントからの引用です。

エフェクトは、特定のイベントによってではなく、レンダー自体によって引き起こされる副作用を指定するためのものです。

エフェクトは通常、React のコードから「踏み出して」、何らかの外部システムと同期するために使用されるものだということを肝に銘じてください。これには、ブラウザ API、サードパーティのウィジェット、ネットワークなどが含まれます。

この通りuseEffectはレンダーによって引き起こされる副作用を指定するものです。つまりコンポーネントと直接的に関係ないものや責務から外れたものなどは記述すべきではありません。
基本的にはReactの外部システムや環境と同期させる事が役目です。
外部システム」とは、Reactによって制御されていないシステムのことです。例えば以下のようなものが該当します。

  • ネットワーク
  • サードパーティのウィジェット
  • ブラウザAPI

これらのシステムはReactの外部にあるもので、Reactの状態管理やレンダリングサイクルとは独立して動作します。
繰り返しになりますがこの様な外部システム達と同期させる事がuseEffectの役割になります。

useEffectの仕様について知る

続いてuseEffectの仕様について確認します。

実行タイミングについて

まずはuseEffectの実行タイミングについてですが公式ドキュメントに以下の通り記述されています。

エフェクトは、コミットの最後に、画面が更新された後に実行されます。ここが、React コンポーネントを外部システム(ネットワークやサードパーティのライブラリなど)と同期させるのに適したタイミングです。

ここでいうコミットとはReactがDOMの変更を行う事を指しています。
少し脱線しますがReactのレンダーサイクルは3つのステップに別れます。レストランでイメージしてみます。

  1. レンダーのトリガ(お客様の注文を厨房に伝える)
  2. コンポーネントのレンダー(厨房で注文の品を料理する)
  3. DOM へのコミット(テーブルに注文の品を提供する)


出典: 公式ドキュメント レンダーとコミット

さて、話は戻りこのコミット後にuseEffectは実行されます。よくある実装例だとuseStateのset関数によってstateが更新されるとレンダーのトリガーとなります。順にコンポーネントがレンダーされて差分があるDOMのみを変更し最後にuseEffectの処理が行われるという流れになります。

不必要な再実行をスキップする

周知の通りuseEffectは第二引数(依存配列)によって再実行をコントロールすることが出来ます。パターンは大きく分けて3つです。それぞれどんな違いがあるか正確に理解して適切なタイミングで実行されるようにしましょう。

1. 第二引数なし
useEffect(() => {
  // 毎回レンダーされる度に実行される
});
2. 第二引数に空配列を指定
useEffect(() => {
  // マウント時(コンポーネント出現時)のみ実行される
}, []);
3. 第二引数に値を指定
useEffect(() => {
  // マウント時と、a か b の値が前回のレンダーより変わった場合に実行される
}, [a, b]);

クリーンアップ処理について

useEffecetにはコンポーネントがアンマウントされる時やエフェクト処理が再実行される前に行いたい処理を記述できこれをクリーンアップ処理と言います。
公式ドキュメントのリアクティブなエフェクトのライフサイクルのページに以下の様な記述があります。

エフェクトは、クリーンアップ関数を返さないことがあります。多くの場合は返すべきですが、もし返さなかった場合は、空のクリーンアップ関数が返されたとして扱われます。

記述方法は以下の通りです。

useEffect(() => {
  const handleScroll = () => {
    console.log('スクロールしたよ');
  };

  window.addEventListener('scroll', handleScroll);

  // 🧹 クリーンアップ処理(イベント解除)
  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, []); // 初回のみ実行

クリーンアップ処理をしなければコンポーネントがアンマウントされてもよしなにイベント解除をしてくれません。ですのでこの場合クリーンアップ処理を忘れてしまうと一度登録されたイベントは不要になったとしても無駄にずっと動き続ける事になりメモリリークする事になります。

どんな時に使用すべきか

再度の引用になってしまいますが公式ドキュメントにはこの様に書かれています。

何らかの外部システムと同期するために使用されるものだということを肝に銘じてください。

つまり外部システムと同期する目的以外で使用する場合推奨から外れるわけです。そう考えると意外とシンプルで使用機会も多くはないはずです。
よくある身近な実装だとブラウザAPIを利用するシーンでしょうか。

イベントハンドラを扱う

const Modal = ({ isOpen, onClose }) => {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onClose()
      }
    }

    if (isOpen) {
      window.addEventListener('keydown', handleKeyDown)
    }

    return () => {
      window.removeEventListener('keydown', handleKeyDown)
    }
  }, [isOpen, onClose])

  if (!isOpen) return null

  return (
    <div className="fixed inset-0 bg-black/50 flex items-center justify-center">
      <div className="bg-white p-4 rounded shadow">モーダルの中身</div>
    </div>
  )
}

この例ではReactのイベントシステムでは扱えない、ウィンドウ全体に対するイベント(例: keydown)を扱うため、外部システムのブラウザAPIを利用し、useEffect内で同期しています。

データをフェッチする

const User = () => {
  const [name, setName] = useState('')

  useEffect(() => {
    fetch('https://hoge.com/users/1')
      .then(res => res.json())
      .then(data => setName(data.name))
  }, [])

  return <p>ユーザー名: {name}</p>
}

ただし今日のWebアプリケーションにおいてわざわざこの様にデータをフェッチする事は多くないと思います。
あくまでuseEffectの利用シーンとしては問題ないだけで基本的にはSWRやTanStack Queryなどのライブラリを用いてデータフェッチを行う事が多いと思います。

アンチパターンについて

先ほど「外部システムと同期する目的以外で使用する場合推奨から外れる」と言ったわけですが、現実的に推奨から外れるuseEffectを全て削除するというのは中々難しいと思います。
そこでまずは自身のプロジェクトで以下の様な使い方をしている箇所から適宜修正してみる事をお勧めします。
以下紹介している例は公式ドキュメントのそのエフェクトは不要かもからいくつかピックアップしたものを噛み砕いて説明したものになります。

propsやstateに基づいてstateを更新したいとき

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

  // useEffectでfirstNameとlastNameからfullNameを計算する
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

何となくで使ってしまうとこんな使い方をする事があると思います。
実行タイミングについてでレンダーサイクルについて述べた通りuseEffectはレンダーサイクルの最後に実行されます。つまりfirstNameまたはlastNameが更新されてもレンダーサイクルは一旦古いstateのまま進んでいき最後にuseEffectによって新しくなったstateでfullNameを更新しまた新たにレンダーサイクルが始まってしまいます。
以下、公式ドキュメントのそのエフェクトは不要かもより引用

既存の props や state から計算できるものは、state に入れないでください。代わりに、レンダー中に計算します。

シンプルに以下の通り修正できます。

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // レンダリング中に計算する
  const fullName = firstName + ' ' + lastName;
  // ...
}

propsが変更されたときにすべてのstateをリセットする

export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  // propsで受け取ったuserIdが変わったらコメントを空文字にする
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

これも先程同様無駄なレンダーサイクルが走ってしまいます。
今回のようにuserIdが異なる場合にstateを共有して欲しくない時はkeyを指定する事で解決が可能です。
mapを使用する際にもkeyを指定しますがこれはReactにそれぞれ異なるコンポーネントだと伝える事でそれぞれで独立しstateを共有しない役割があります。
つまりProfileコンポーネントのkeyにuserIdを指定する事でuserIdが変わった際にDOMを再生成しstateをリセットする事が可能です。

export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // comentは呼び出し元で指定したkeyが変わると自動でリセットされる
  const [comment, setComment] = useState('');
  // ...
}

propsが変更されたときに一部のstateを調整する

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // itemsの変更があった場合にselectionをリセットする
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

お分かりの通りこれも非効率なレンダーサイクルを引き起こしてしまいます。値の変更を追従させる為にuseEffectを使用するのは良くありません。
この場合次の通り修正が可能です。

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // レンダー中にstateを調整する
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

少しまどろっこしい感じもしますが新旧で値を比較させる事で変更を検知する事ができます。
しかし、大抵の場合この様な手段を取るまでもありません。要件や仕様を改めて確認し本質的にどうあるべきかを考えてみると先程のkeyで全てのstateをリセットしたりレンダー中に計算したりする方法などで解決できる事が多いです。

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // レンダー中に計算する
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

要件や仕様上問題ないのであれば多少挙動は変わるがよりシンプルに実装する事もできます。
余談ですが実装中はつい視野が狭くなったり思考ロックに陥る事があるので一度立ち止まってそもそも何でこの実装をしているのか?別の代替手段はないか?など手を止めて整理する事も大事だと思います。

重たい計算のキャッシュ

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');

  // todosとfilterを追従しvisbleTodosを計算する
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);

  // ...
}

結果として期待通り動くかも知れませんが冗長かつ非効率的です。また、todosが大量にある場合計算に時間がかかるかも知れません。再レンダーの度に時間のかかる計算が毎回走ってしまうのは明らかにパフォーマンスが良くありません。
この様な場合メモ化を行い不要な再計算をしない様にできます。

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  const visibleTodos = useMemo(() => {
    // todosまたはfilterに変更が無ければ再計算しない
    return getFilteredTodos(todos, filter);
  }, [todos, filter]);
  // ...
}

React19からはReact Compilerにより明示的にメモ化をしなくても良くなりました。
これにより開発者は特に意識せずシンプルに実装が可能になりました。

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // 明示しなくてもメモ化が出来ている
  const visibleTodos = getFilteredTodos(todos, filter);
  // ...
}

他にも以下のようなアンチパターンが書かれているので是非目を通してみる事をお勧めします。

  • イベントハンドラ間でのロジックの共有
  • 計算の数珠繋ぎ
  • 親コンポーネントへのstate変更の通知
  • 親にデータを渡す
  • 外部ストアへのサブスクライブ

終わりに

今回はReactの主要なフックでありながら扱いが難しいuseEffectについてまとめてみました。
はじめにuseEffectの本来の役割や仕様面を見てみる事でなぜNGなのか理解しやすくなりどんなケースだと利用を避けるべきかなど判断しやすくなると思います。
ただし、情報の信頼性を高める為に必ず一次情報を確認する事をお勧めします。今回の記事もそうですが二次情報は悪意の有無に関わらず信頼性が低くなりがちですのであくまで参考程度に留めるのが吉です。
他の技術に触れる際にも言える事ですが、いきなり公式ドキュメントを見ても理解しにくいなと思った時などまずは噛み砕いて説明してくれている記事などを読んで前提知識を高めてから再度読んでみると理解しやすくなります。
正しく理解し扱える様になると言うことはエンジニアとして本質的な対応が出来る様になるという事だと思いますのでまずは身近なものから1つずつやっていく事が大事かと思います。

最後に本記事において誤解や表現に誤りを見つけて下さった方コメントでご指摘頂けるとありがたいです。

参考文献

React公式ドキュメント
https://ja.react.dev/learn/synchronizing-with-effects
過激派が教える! useEffectの正しい使い方
https://zenn.dev/uhyo/articles/useeffect-taught-by-extremist
結局 useEffect はいつ使えばいいのか
https://zenn.dev/yend724/articles/20240711-qfbqiba6m9iul2al

Discussion