👋

なんとなくでuseEffectを使うのをやめたい。

2023/11/11に公開

はじめに

前回のuseStateに続いて、useEffectについてもReactドキュメントの解説をもとに学んでいきます。

useEffectとは?

useEffect は、コンポーネントを外部システムと同期させるための React フックです。

useEffect(setup, dependencies?)

引数にはエフェクト内で実行する関数と依存値を受け取り、undefinedを返します。
useEffectに渡す依存値は開発者が自ら選んで設定するものではなくエフェクトのコードで使用されるすべてのリアクティブな値を宣言しなければなりません。

「useEffect リファレンス」はこちら
https://ja.react.dev/reference/react/useEffect

ドキュメントには以下のような記述があります。

エフェクトは「避難ハッチ」です。React の外に出る必要があり、かつ特定のユースケースに対してより良い組み込みのソリューションがない場合に使用します。エフェクトを手で何度も書く必要があることに気付いたら、通常それは、あなたのコンポーネントが依存する共通の振る舞いのためのカスタムフックを抽出する必要があるというサインです。

また、ドキュメントの「エフェクトは必要ないかもしれない」には、外部システムが関与していない場合はエフェクトは必要ないと断言されています。

外部システムが関与していない場合(例えば、props や state の変更に合わせてコンポーネントの state を更新したい場合)、エフェクトは必要ありません。

useEffectの依存値を空配列にすることでレンダリング時に1度だけ実行させる、useStateで管理している値や何らかの値の変更を検知して関数を実行するといったものはNGのようです。
このような不要なuseEffectを削除することで、コードの可読性や実行速度、エラーの発生予防に繋がることになります。

useEffectの注意点と使用例

useEffectを使う上での注意点、使用例はリファレンスに記載されています。

Reactコンポーネント内のロジックについて

エフェクトの説明の前にReactコンポーネント内の2種類のロジックを理解しておく必要があるようです。

  1. レンダーコード(UI の記述で説明)
    コンポーネントのトップレベルにあるもので、受け取ったpropsやstateを変換してJSXを返す場所。数学の式のように結果を計算するだけで他のことは行わない。

  2. イベントハンドラ(インタラクティビティの追加で説明)
    コンポーネント内にネストされた関数であり、計算だけでなく何かを実行するもの。(入力フィールドの更新、HTTP POSTリクエスト、画面遷移など)
    特定のユーザアクションによって引き起こされる副作用が含まれる。

これらのロジックについても以下ドキュメントで詳しく説明されています。

https://ja.react.dev/learn/describing-the-ui

https://ja.react.dev/learn/adding-interactivity

エフェクトの書き方

エフェクトを書くにはReactからuseEffectフックをインポートし、コンポーネントのトップレベルで呼び出します。

import { useEffect } from 'react';

function MyComponent() {
  useEffect(() => {
    // Code here will run after *every* render
  });

  return <div />;
}

コンポーネントがレンダーされるたびにReactが画面を更新し、その後にuseEffect内のコードが実行するようです。
useEffect内のコードの実行タイミングは「画面が表示されたら」、「依存値が変更されたら」くらいのなんとなくしかわかっていなかったですが、レンダー結果が画面に反映されてから初めてコードが実行されるらしいです。

エフェクトの例

<video>タグを使ったエフェクトの例を見てみます。

function VideoPlayer({ src, isPlaying }) {
  // TODO: do something with isPlaying
  return <video src={src} />;
}

この例ではvideoタグのplay()pause()メソッドを手動で呼び出し、isPlayingと同期させる必要があるため、<video> DOM ノードへのrefを取得します。
ここで注意しなければいけないのは以下の2点です。

  • Reactは「レンダーはJSXの純粋な関数であるべき」であるため、DOMの変更のような副作用を含んではいけない
  • VideoPlayerが初めて呼び出されるときはDOM自体が存在していないため、JSXが返されるまではplay()pause()を呼び出すためのDOMノードは存在していない

このようなときに副作用をuseEffectでラップして、レンダーの計算処理の外に出すことで解決することができます。
こうすることでplay()pause()はReactが画面を更新するまで実行されず、更新が完了したら実行されるようになります。

import { useEffect, useRef } from 'react';

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => {
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  });

  return <video ref={ref} src={src} loop playsInline />;
}

デフォルトではエフェクトは全てのレンダー後に実行されてしまうため、useEffectの第二引数に依存値の配列を指定する必要があります。
依存値を指定することで不必要な再実行をスキップするようReactに指示することができます。

  useEffect(() => {
    if (isPlaying) { // It's used here...
      // ...
    } else {
      // ...
    }
  }, [isPlaying]); // ...so it must be declared here!

こうすることでisPlayingに変更がなければエフェクトの再実行をスキップさせることができます。
なお、依存値が正しく指定されていないとReact Hook useEffect has a missing dependency: 'isPlaying'というエラーが表示されるため、エフェクト内のコードの依存関係を正しく指定する必要があります。
依存値のエラーをコメントで無視することが多々あるのですが、依存配列がコードと一致しない場合、バグが発生するリスクが非常に高くなるとのことでした。

このリンタを止めてしまうと、見つけたり修正したりするのが難しい、非常に分かりづらいバグの原因になります。

依存配列に関するリントエラーはコンパイルエラーとして扱うことをお勧めします。

https://ja.react.dev/learn/removing-effect-dependencies#why-is-suppressing-the-dependency-linter-so-dangerous

以下のような記述もあるため、useEffectの依存値は正しく指定すべきものです。このあたりは今まで意識できていなかった部分ですので、useEffectを使う前に本当に必要なのかも考える、useEffectを使う時は依存配列に気をつけていきたいところ…。

依存値は自分で「選ぶ」ようなものではありません。エフェクト内のコードに基づいて React が期待する配列と、指定した依存配列が合致しない場合、リントエラーが発生します。これにより、コード内の多くのバグを検出することができます。一部のコードを再実行しない場合は、エフェクトのコード自体を編集して、その依存値が「必要」とならないようにします。

なお、依存配列がない場合と空の[]という依存配列がある場合、依存配列が指定された場合の挙動は以下の通りです。

依存配列による挙動の違い
useEffect(() => {
  // 毎回のレンダー後に実行される
});

useEffect(() => {
  // マウント時(コンポーネント出現時)のみ実行される
}, []);

useEffect(() => {
  // マウント時と、a か b の値が前回のレンダーより変わった場合に実行される
}, [a, b]);

※エフェクトはデフォルトでは毎回のレンダーの後に実行されるため次のようなコードは無限ループが発生します。

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

useStateの説明でもあったようにstateの設定はレンダーをトリガーします。

この例ではエフェクトが実行→stateがセット→再レンダーが発生→エフェクトが実行→stateがセット→再レンダーが発生…ということになります。
このようなことが起きてしまうため、外部システムではなくstateに基づいてstateを調整するといった場合はエフェクトは必要ないとのことです。
実際の実装でも、ある値が依存値に入っていると無限ループが発生してしまうため、その値を依存値から削除するといったことをやってしまいがちですがエフェクト内に外部システムがない場合はエフェクト自体を削除した方が良いみたいです。
エフェクトの依存値に関しての詳細は以下にあります。
https://ja.react.dev/learn/removing-effect-dependencies

最後に必要に応じてクリーンアップする必要があるものはエフェクトからクリーンアップ関数を返すようにします。
以下の場合はコンポーネントが表示されている間は接続を維持し、コンポーネントがアンマウントされた時は接続を解除する必要があります。

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []);

開発環境だとエフェクト内のコードが2度実行されますが、これは開発環境の正しい動作でドキュメントにも以下のような記述があります。

コンポーネントの再マウントは、クリーンアップが必要なエフェクトを見つけるために開発中にのみ行われます。Strict Mode を外すことで、開発時専用のこの挙動をオフにすることができますが、オンにしておくことをお勧めします。これにより、上記のような多くのバグを見つけることができます。

開発環境での2回発生するエフェクトについての詳細は以下にあります。
https://ja.react.dev/learn/synchronizing-with-effects#how-to-handle-the-effect-firing-twice-in-development

データのフェッチ

クライアント側でデータを取得したい場合は以下のようにすると良いようです。

useEffect(() => {
  let ignore = false;

  async function startFetching() {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

  return () => {
    ignore = true;
  };
}, [userId]);

ただ、ドキュメントには以下のような記述もあり、フレームワークを使っている場合はそれらの組み込みのデータフェッチ機構を、それ以外の場合はTanStack QueryuseSWR等を使用することが勧められています。

特に完全にクライアントサイドのアプリにおいては、エフェクトの中で fetch コールを書くことはデータフェッチの一般的な方法です。しかし、これは非常に手作業頼りのアプローチであり、大きな欠点があります。

上記の欠点は、マウント時にデータをフェッチするのであれば、React に限らずどのライブラリを使う場合でも当てはまる内容です。ルーティングと同様、データフェッチの実装も上手にやろうとすると一筋縄ではいきません。

https://ja.react.dev/learn/synchronizing-with-effects#what-are-good-alternatives-to-data-fetching-in-effects

また、現在Canaryですがuseフックというものがあり、将来的にはこちらを使うことが推奨されるかもしれません。

アプリケーション初期化はエフェクトではない

アプリケーションの初期化のためのロジックを以下のように依存配列を[]とすることで実行することがよくあるのですが、ドキュメントによると初期化のためのロジックのような一度だけ実行されるべきものはコンポーネントの外に置くことができるようです。

このように書くことでページ読み込み時に1度だけ実行されることが保証できるようです。

🔴 NG
function App() {
  useEffect(() => {
    // 初期化のためのロジック
    checkAuthToken();
    loadDataFromLocalStorage();
  }, [])
}
✅ OK
if (typeof window !== 'undefined') {
  // 初期化のためのロジック
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

「エフェクトの書き方」にはこれらの他にも様々な事例が記載されていました。
https://ja.react.dev/learn/synchronizing-with-effects#how-to-write-an-effect

不要なuseEffectの削除について

ドキュメントには以下の記述があり、基本的にはエフェクトは不要なようです。

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

慌ててエフェクトをコンポーネントに追加しないようにしましょう。エフェクトは通常、React のコードから「踏み出して」、何らかの外部システムと同期するために使用されるものだということを肝に銘じてください。これには、ブラウザ API、サードパーティのウィジェット、ネットワークなどが含まれます。エフェクトが他の state に基づいて state を調整しているだけの場合、おそらくそのエフェクトは必要ありません。

不要なuseEffectの削除についての具体例もあげられていますので見てみます。

  1. props または state に基づいて state を更新する

useStateで管理しているfirstNamelastNameが変更されたらuseEffect内でフルネームを作成しています。
普段の実装でもやりがちですが、これだと古くなったfullNameで最後までレンダーされた直後に更新されたfullNameで再レンダーをやり直すことになるようでかなり非効率のようです。

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

  // 🔴 Avoid: redundant state and unnecessary Effect
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

この実装は以下のように。state変数とエフェクトを削除することができます。

✅ 修正後
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ Good: calculated during rendering
  const fullName = firstName + ' ' + lastName;
  // ...
}

fullNameは既にあるfirstNamelastNameというstateから計算可能なものであり、それらはエフェクトを使う必要はありません。

既存の props や state から計算できるものは、state に入れないでください。代わりに、レンダー中に計算します。これによりコードは(余分な「連動」更新処理が消えたことにより)高速になり、(コードを削減したことにより)簡潔になり、さらに(異なる state 変数が同期しなくなるバグを回避できたことにより)エラーも少なくなります。このアプローチになじみがない場合は、React の流儀で state に入れるべきものを説明しています。

  1. 計算の連鎖
    あるstateをもとに、stateを調整するようにエフェクトを書くこともよくやってしまうことですが、これも削除できるエフェクトのようです。
    以下の実装を見てみます。
🔴 修正前
function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);

  // 🔴 Avoid: Chains of Effects that adjust the state solely to trigger each other
  useEffect(() => {
    if (card !== null && card.gold) {
      setGoldCardCount(c => c + 1);
    }
  }, [card]);

  useEffect(() => {
    if (goldCardCount > 3) {
      setRound(r => r + 1)
      setGoldCardCount(0);
    }
  }, [goldCardCount]);

  useEffect(() => {
    if (round > 5) {
      setIsGameOver(true);
    }
  }, [round]);

  useEffect(() => {
    alert('Good game!');
  }, [isGameOver]);

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    } else {
      setCard(nextCard);
    }
  }

  // ...

この実装ではuseStateで管理しているcard,goldCardCount,round,isGameOverそれぞれの変更を検知するエフェクトを書いており、エフェクト内でstateを更新する処理を行っています。

問題点は以下の通りです。

  • 非常に効率が悪い
    エフェクト内の各セット関数が呼ばれるたびに毎回再レンダーする必要があり、この例では最悪の場合、setCard → レンダー → setGoldCardCount → レンダー → setRound → レンダー → setIsGameOver → レンダーの3回の不要な再レンダーが発生してしまいます。
    再レンダーが直接アプリケーションの速度に影響しなかったとしても、開発が進むにつれて機能を追加しようとしたときにエフェクトの連鎖処理のなかで開発者が意図していない、予測できない挙動が出てくる可能性があり、良いコードとは言えないです。

このような場合、レンダー中に計算できる(既存のstatepropsから計算可能)ものはレンダー中に行い、イベントハンドラでstateの調整を行うと良いようです。

✅ 修正後
function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);

  // ✅ ゲームオーバーはroundから判定可能なためレンダー中に計算してしまう
  const isGameOver = round > 5;

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    }

    // ✅ イベントハンドラで次の状態をすべて計算する
    setCard(nextCard);
    if (nextCard.gold) {
      if (goldCardCount <= 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        setRound(round + 1);
        if (round === 5) {
          alert('Good game!');
        }
      }
    }
  }

イベントハンドラ内で次のstateを直接計算できず、ネットワークとの同期が発生するような場合はエフェクトを連鎖させることは適切であり、基本的にReact内部で完結できるものはエフェクトを使う必要はなさそうです。

「エフェクトは必要ないかもしれない」には他にも様々なものが記載されているためかなり学びが多いと思います。

https://ja.react.dev/learn/you-might-not-need-an-effect

まとめ

今まではstateの変更を検知して何らかの処理を行ったり、レンダー時に実行したい処理を行いたいときにuseEffectを使うことが多かったですが、ドキュメントには以下の記述のように、外部システムが関係していない場合はuseEffectは必要なく、むしろ非効率であったり、意図しないバグを埋め込む可能性があるため、使わないにこしたことはないものでした。

外部システムが関与していない場合(例えば、props や state の変更に合わせてコンポーネントの state を更新したい場合)、エフェクトは必要ありません。

また、以下のポストによるとReact開発元のMetaのコードベースでさえ、ランダムな128個のuseEffectの内59個は不要なものだったようで、なんとなくで使うuseEffectの場合、その多くは不要なものだと言えるかもしれません…。
https://twitter.com/dan_abramov/status/1638773531881140224

この記事であげているもの以外にもエフェクト関連の記事はありますので、一度目を通すだけでもかなりの学びにつながると思います。

Discussion