📖

「Reactを学ぶ」で理解するuseEffect要約版

2024/02/14に公開

この記事はreact.devにあるuseEffect関連の要約です。
自分なりの理解を整理したり、迷ったときに参照できる情報を1箇所にまとめたかったことがモチベーションになっています。
探し物をしたり、useEffectに関連するトピックを知る用途で有効かもしれません。

リファレンス

useEffect(setup, dependencies?)
useEffect(() => {
  // セットアップ関数
  return () => {
  // クリーンアップ関数
  };
}, [依存配列]); // 返り値は`undefined`

宣言

import { useEffect } from 'react';
  • useEffectは必ずコンポーネントやカスタムフックのトップレベルで呼び出す必要があります
  • クライアント上でのみ実行され、サーバーレンダリング中には実行されません

セットアップ関数

  • コンポーネントのマウント時と再レンダー時に実行されます

依存配列

  • 3つの定義方法があります
    • 省略:コンポーネントがレンダーされるたびに実行されます
    • 空配列:初回のレンダー時のみ実行されます
    • 配列に値を指定:リアクティブな値が更新されたら実行されます
  • リアクティブな値:Reactが検知可能な値のことで、props、state、コンポーネントに宣言された変数と関数を指します
  • 依存配列が変更された場合の挙動
    1. (クリーンアップ関数が存在する場合)古い値を使ってクリーンアップ関数を実行する
    2. 新しい値を使ってセットアップ関数を実行する
    3. (コンポーネントがアンマウントされたら)クリーンアップ関数を実行する
  • 依存値の比較にはObject.isが使用されます
    • Object.is() - JavaScript | MDN
    • オブジェクトや配列の場合は、値が同じでも参照先が異なると違うものとして扱われます
    const obj1 = {a: 'a', b: 'b'};
    const obj2 = {a: 'a', b: 'b'};
    const obj3 = obj1;
    
    console.log(Object.is('str1', 'str1')); // true
    console.log(Object.is('str1', 'str2')); // false
    Object.is(25, 25); // true
    Object.is(25, '25'); // false
    
    console.log(Object.is(obj1, obj1)); // true
    console.log(Object.is(obj1, obj2)); // false
    console.log(Object.is(obj1, obj3)); // true
    console.log(JSON.stringify(obj1) === JSON.stringify(obj2)); // true
    
    Object.is(null, null); // true
    Object.is(undefined, undefined); // true
    Object.is(window, window); // true
    Object.is([], []); // false

Strict Modeでの挙動

  • 開発環境モードでは誤った動作を発見するため、セットアップ関数の前に、セットアップ関数とクリーンアップ関数が1回ずつ実行されます

エフェクトを使って同期をおこなう

  • Reactコンポーネントには2種類のロジックがあります
    1. レンダーコード:コンポーネントのトップレベルに存在し、propsやstateを受け取り、返還し、画面に表示するJSXを返す場所
    2. イベントハンドラ:コンポーネント内にネストされた関数で、計算や何らかの実行をおこない、利用者のアクションによって引き起こされる、副作用が含まれるもの
  • 場合によっては純粋関数ではなく副作用があり、利用者のアクションを伴わない機能が必要になることがあります
  • エフェクトは特定のイベントではなく、レンダー自体によって引き起こされる副作用を指定するものです
    • 例:サーバー接続やフェッチなどは利用者のアクションに関わらず必要になる
  • エフェクトはDOMへのコミットの最後に、画面が更新された後に実行されます
    • つまりuseEffectは、レンダー結果が画面に反映されきるまでコードの実行を遅らせます
    • そのためレンダー中のDOMノードに副作用を含めてはいけません
      • 初期レンダー時にDOMがJSXにコミットされるまで、ReactはどのようなDOMを作成したのかを検知していないからです
      • Reactがレンダーした後にエフェクトを実行する必要があります
  • エフェクトが他のstateをもとにstateを調整している場合、おそらくエフェクトは不要です

なぜ ref は依存配列にないのか?

  • Reactは同じuseRefコールから常に同じオブジェクトが返されることを保証しているからです
    • 依存配列に指定しても値は変更されていないため、常にスキップされます
    • useStateのset関数も同様に依存配列から省略できます
  • 例外として、useRefが親コンポーネントから渡される場合は常に同一であることが保証できないため、依存配列に指定する必要があります

エフェクトを使ったデータフェッチ

  • フレームワークを使用している場合は、フレームワーク固有の標準的なソリューションが推奨されます
    • フレームワーク:Next.js、Remix、Expoなど
    • ソリューション:React Query、useSWR、React Router 6.4+など
  • 次のような欠点があるからです
  • データの読み込みがアプリのレンダー後に実行されます
    • Reactは「Loading…」を表示してからデータを読み込む必要があることに気がつきます
  • 親子コンポーネントごとにフェッチしている場合、並列ではなく順次実行になり、処理が遅くなります
  • データのプリロードやキャッシュができません
  • 競合状態を避けるための記述で煩雑になります

そのエフェクトは不要かも

レンダーのためのデータ変換にエフェクトは必要ありません

  • エフェクトでstateを更新すると、レンダー後すぐにエフェクトが実行されます
    • つまりレンダーが2回発生してしまいます
  • コンポーネントのトップレベルで、すべてのデータ変換をするようにします

ユーザイベントの処理にエフェクトは必要ありません

  • イベントハンドラの時点でユーザーイベントを正確に把握できます
    • つまりエフェクトで処理を記述する必要がありません
    • エフェクトの実行タイミングでは、イベントの内容も把握できません

既存のpropsやstateから計算できるものは、stateに入れないでください

  • エフェクトを使うと、古い値でレンダーを実行後、更新された値で再レンダーが発生してしまいます
  • エフェクトを使わない場合は次のようなメリットがあります
    • 余分な更新処理を削除して高速にできます
    • コードを削減して簡潔にできます

重たい計算のキャッシュ

  • 前提としてレンダー用の計算をエフェクト内におくことは非効率です
    • stateとエフェクトを削除して、ローカル変数にしてください
  • 計算が重かったり、別のstateによって再計算されてしまうのを避けたい場合はuseMemoでメモ化します
    • 計算処理に使用する引数に変更がない場合は、再計算をせずに、最後に格納した結果を返します
    • useMemoでラップする関数はレンダー中に使用されるため、純粋な計算にする必要があります
  • 計算コストが高いかどうかを見分ける方法です
    • 一般的に、何千ものオブジェクトを作成したりループしない限り高価ではありません
    • 確信を持ちたい場合は、console.time()とconsole.timeEnd()でログを取得します
      • 全体のログ時間が1ms以上になる場合はメモ化する意味があるかもしれません
    • 以下の点に注意してください
      • useMemoは初回レンダーを高速化せず、更新時に不要な作業をスキップします
      • CPUスロットリングオプションを使って、意図的に処理速度を低下させて計測してください
      • 開発環境ではコンポーネントが2回レンダーされる可能性があるので、ステージングや本番環境と同一のビルド内容でテストしたり、ユーザーが持っているようなデバイスでテストをしてください

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

  • エフェクトを使うと、古い値でレンダーを実行後、更新された値で再レンダーが発生してしまいます
  • stateに依存するコンポーネントが複数ある場合にも、すべてのコンポーネントが再レンダーされてしまいます
  • コンポーネントにkeyを渡すことで、Reactに別のコンポーネントであることを知らせます
    • stateではなくコンポーネント単位で変更があるかを比較するため、同値であれば再レンダーされなくなります

イベントハンドラ間でのロジックの共有

  • ユーザー操作によってコンポーネントが表示される場合は、エフェクトではなく新しい関数に共有ロジックを切り出し、イベントハンドラから呼び出します
  • POST リクエストの送信も同様です

計算の数珠繋ぎ

  • あるstateの変更に基づいて別のstateを調整したい場合、各エフェクトごとにレンダーが発生するため効率が悪く、コードが複雑になりバグが起きやすくなります
  • stateからローカル変数にできないか検討してください
  • イベントハンドラ内で調整ができないか検討してください
  • 複数のイベントハンドラ間でロジックを再利用する場合は、関数を抽出してハンドラから呼び出してください
  • イベントハンドラ内で次のstateを計算できない場合はエフェクトが適切な可能性があります

アプリケーションの初期化

  • アプリが読み込まれるときに一度だけ実行したいロジックをトップレベルのコンポーネントに配置すると、バグが起こる可能性があります
    • 開発環境では2回実行されますトップレベルを含めたすべてのコンポーネントは再マウントに対応できるようにする必要があります
  • トップレベルに変数を追加して、初期化されたかを保持してください
  • if (typeof window !== 'undefined')とすればモジュールの初期化中やアプリのレンダー前に実行可能です
  • このパターンは過剰に使用しないようにします
    • コンポーネントをインポートする際の遅延や予期せぬ動作を避けるためです

親コンポーネントへの state 変更の通知

  • 子コンポーネントでstateが変更されたことを親コンポーネントに通知するためにエフェクトを使わないようにします
    • 親コンポーネントのレンダー前に子コンポーネントのレンダーも実行されてしまうからです
  • エフェクトを削除して、同じイベントハンドラ内で両方のコンポーネントにあるstateを更新します
    • 子コンポーネントのレンダーはされないので、レンダー処理は1回だけにできます
  • stateを完全に削除して、親コンポーネントからのpropsで制御することもできます
  • 2つの異なるstate変数を同期させたいと思ったら、stateのリフトアップを試してください

親にデータを渡す

  • Reactではデータを親から子に渡すようにします
    • 子から親にデータを渡すと、追跡が困難になるためです
  • 子と親が同じデータを必要としているなら、親コンポーネントがデータを取得します

外部ストアへのサブスクライブ

  • コンポーネントが外部にあるデータをサブスクライブ(購読)する必要がある場合は、useEffectよりもuseSyncEcternalStoreが推奨されます

外部システムと同期する必要がない

  • useEffectはReactによって制御されていない外部(external)との同期をさせるために使います
  • 外部システムの例
    • サーバーとの接続
    • DOM:window.addEventListener() IntersectionObserver useRef()

ブラウザによる画面の再描画をブロックしなければならない場合

  • ユーザーの操作以外でエフェクトを利用する場合で、遅延やチラつきが問題になるとき
    • 新しい画面を描画した後にエフェクトが実行されるのが原因です
  • ユーザーの操作でエフェクトを利用する場合で、エフェクト内にあるstate更新処理の前に起こる画面の再描画をブロックしたいとき
    • 画面を描画してからエフェクト内のstate更新処理が発生するのは、通常望ましい動作です

トラブルシューティング

エフェクトが再レンダーごとに実行される

エフェクトが無限ループで再実行され続ける

  • 無限ループは次の2つの条件が成立する場合に発生します
    1. エフェクトが何らかのstateを更新している
    2. そのstateの更新によって再レンダーが発生し、エフェクトの依存配列も変更される
  • エフェクトが外部システムと接続していない場合は、エフェクトは不要なので別の方法に変更します
  • 外部システムとの接続が必要な場合は、次の方法を試します
    • レンダーに使用されないデータを管理したい場合は、再レンダーをトリガしたいないuseRefが適切かもしれません
    • エフェクトが必要以上にstateを更新しているかもしれません
    • 依存配列をconsole.log()で出力してデバッグしてください
  • 次のコードは無限ループを引き起こします
    1. 依存配列を省略しているので、レンダーごとにエフェクトが実行されます
    2. count変数がインクリメントされます
    3. stateが更新されたのでコンポーネントが再レンダーされ、その後にエフェクトが実行されます
    4. 1.に戻る

コンポーネントがアンマウントされていないのにクリーンアップロジックが実行されてしまう

  • クリーンアップ関数はアンマウント時以外にも次のタイミングで実行されます
    • 依存配列が変更され、再レンダーがおこなわれた後に実行されます
    • 開発中には、コンポーネントのマウント直後に、セットアップ関数とクリーンアップ関数が1回追加で実行されます
  • セットアップ関数がないのにクリーンアップ関数を実行していると問題が起きることがあります

エフェクトが表示に関することをおこなっており、実行前にちらつきが見られる

  • エフェクトがブラウザの画面描画をブロックする必要がある場合は、useEffect の代わりに useLayoutEffectを使用します
  • ほとんどエフェクトでは不要です
    • パフォーマンスを低下される可能性があります
    • ブラウザの描画前にエフェクトの実行が必要な場合のみ必要になります
    • 例:描画前に描画する要素のサイズを取得したい
  • Reactでは通常、次のプロセス(ブラウザレンダリング)が実行されます
    1. レンダーがトリガされる
      • レンダーは2つの理由で起こります
        1. アプリ開始時の初回レンダー
        2. state更新後の再レンダー
    2. Reactがコンポーネントをレンダーする
      • レンダーとは、Reactがコンポーネントを呼び出して画面に表示する内容を把握することです
      • レンダーが起きるプロセス
        • 初期レンダー:Reactはルート(root)コンポーネントを呼び出します
        • 次回以降のレンダー:stateの更新によってレンダーがトリガされた関数コンポーネントを、Reactが呼び出します
      • このプロセスは再起的に発生するため、親子関係にあるコンポーネントは順番にレンダーされます
    3. ReactがDOMへの変更をコミットする
      • コミットが起きるプロセス
        • 初期レンダー:ReactはappendChild()を使用して、作成したすべてのDOMノードを画面に表示します
        • 再レンダー:Reactはレンダー間で変化した必要最小限のものだけを適用します

サーバーとクライアントで異なるコンテンツを表示する

  • サーバーサイドレンダリング(SSR)を使用している場合、ハイドレーションをおこなう都合上、サーバーとクライアントの 初期HTMLは同一である必要があります
    • ハイドレーション:サーバーで生成したHTMLにクライアント側で動的な振る舞いを活性化させること
  • まれにクライアント側で異なるコンテンツの表示が必要な場合があります
    • 例:localStorageからデータを読み込む場合
  • 次の方法で実装できます
function MyComponent() {
  const [didMount, setDidMount] = useState(false);

  useEffect(() => {
    setDidMount(true);
  }, []);

  if (didMount) {
    // ロードが完了したらエフェクトが実行され、クライアント側のJSXを返す
  }  else {
    // ロード中は初期JSXを返す
  }
}
  • 接続の遅いユーザーは、場合によって数秒以上待たされる可能性があることに注意して、次のことを検討してください
    • コンポーネントの見た目に違和感がないようにします
    • 多くの場合ではCSSでの表示切り替えで対応できます

エフェクトから依存値を取り除く

依存配列はコードに合わせるべき

依存値を削除したければ依存値でないことを示す

  • エフェクトで使用されるリアクティブな値は、依存配列に宣言する必要があります
  • リアクティブな値には、propsやコンポーネント内に直接宣言されたすべての変数や関数が含まれます
  • 変化することのない値はpropsやコンポーネントから削除して、リアクティブな値ではないと証明する必要があります

依存配列を変更したければコードを変更する

  • その値がリアクティブかどうかは周囲のコードが決めます
  • 依存配列を変更したい場合はコードを修正する必要があります
  • 依存配列のエラーをコメントで無効化するのは避けるべきです
    • バグが発生するリスクが非常に高くなります
    • バグを発見するのが難しくなります

不要な依存値を取り除く

  • 依存配列をコードに合わせて調整するたびに、依存値のどれかが変更されてエフェクトが再実行されることが理にかなっているか検討してください

コードをイベントハンドラに移動すべきでは?

  • 編集中

オブジェクト型や関数型の依存値は、エフェクトが必要以上に再同期される原因となります

  • 次のコードにある依存配列であるoptionsは、レンダーごとに作成されるため、useEffectはレンダーごとに必ず実行されてしまいます
const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  const options = { // 🚩 このオブジェクトは再レンダリングのたびにゼロから作成される
    serverUrl: serverUrl,
    roomId: roomId
  };

  useEffect(() => {
    const connection = createConnection(options); // エフェクト内部で使用される
    connection.connect();
    return () => connection.disconnect();
  }, [options]); // 🚩 結果として、この依存関係は再レンダリング時に常に異なる
  // ...
  • optionsuseEffect内で宣言し、変更の検知が必要な値(roomId)のみを依存配列に設定します
const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    const options = {
      serverUrl: serverUrl,
      roomId: roomId
    };
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // `roomId`が変更された場合にのみ実行される
  • オブジェクトだけでなく関数の場合も同様です
  • 次のコードは、optionsの値を返すcreateOptions関数を使った例です
const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');

  useEffect(() => {
    function createOptions() {
      return {
        serverUrl: serverUrl,
        roomId: roomId
      };
    }

    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // `roomId`が変更された場合にのみ実行される

開発環境で 2 回発生するエフェクトへの正しい対応

React 以外のウィジェットを制御する

  • 外部システムのメソッドとstateを同期させたい場合、クリーンアップ関数は必要ありません
    • 再マウントはされてしまいますが、2回呼び出しをしても挙動に影響がないためです
useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
  • ただし2回連続で呼び出せないAPIの場合はクリーンアップ関数が必要になります
    • 例:dialogタグのshowModalメソッドは2回呼び出すと例外が発生してしまいます
useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);

イベントのリッスン

  • element.addEventListener(event, function, useCapture);のようにイベントリスナーを登録する場合は、クリーンアップ関数で登録を解除する必要があります

アニメーションのトリガ

  • エフェクトでアニメーションする場合、クリーンアップ関数にはアニメーションの初期値を設定します
useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // アニメーションのトリガ
  return () => {
    node.style.opacity = 0; // 初期値にリセットする
  };
}, []);

データのフェッチ

  • エフェクトでフェッチ(fetch、取得)する場合、クリーンアップ関数はフェッチを中止するかその結果を無視する必要があります
  • 次のコードの場合、userIdの変更前にフェッチしたデータ(古いデータ)が後から到着した場合でも、クリーンアップ関数によってレスポンスを無視されます
useEffect(() => {
  let ignore = false; // ②フラグの初期値を「無視しない」に設定する

  async function startFetching() {
    const json = await fetchTodos(userId); // ③更新されたuserIdでフェッチする
    if (!ignore) { // ④フラグが「無視しない」場合にだけ実行する
      setTodos(json); // ⑤取得したデータをstateに更新する
    }
  }

  startFetching();

  return () => {
    ignore = true; // ⑥フラグを「無視する」に更新する
  };
}, [userId]); // ①userIdが更新されたらエフェクトを開始する
  • 可能であればコンポーネント間でレスポンスをキャッシュするソリューションを採用してください

アナリティクスログの送信

  • 開発環境で2回送信されてしまいますが、そのままにしておくのをオススメします
  • 次の理由からです
    • 挙動として支障がありません
    • 開発のログが本番で計測されることは通常考えられません(設定によって振り分けられる)
    • 開発環境ではファイルを調整するたびにログが計測されるため、余分に計測されてしまうのは避けられません
  • アナリティクスイベントは次の方法でデバッグします
    • ステージング(本番モードで実行される)でデプロイする
    • Strict modeを外して開発環境を実行する
  • エフェクトの代わりに、ルート変更のイベントハンドラからログを送信することも検討してください

Discussion