エフェクトとの同期

一部のコンポーネントでは、外部システムとの同期が必要になります。たとえば、React の state に基づいて React ではないコンポーネントを制御したり、サーバー接続をセットアップしたり、コンポーネントが画面に表示されたときに分析ログを送信したりすることがあります。エフェクトを使用すると、レンダリング後にコードを実行して、コンポーネントを React の外部のシステムと同期させることができます。

以下の内容を学びます

  • エフェクトとは何か
  • エフェクトがイベントとどう違うか
  • コンポーネントでエフェクトを宣言する方法
  • 不必要にエフェクトが再実行されるのをスキップする方法
  • 開発中にエフェクトが 2 回実行される理由と、その修正方法

エフェクトとは何か、イベントとどう違うのか?

エフェクトについて学ぶ前に、React コンポーネント内の 2 種類のロジックに精通しておく必要があります。

  • レンダリングコードUI の記述で紹介)は、コンポーネントのトップレベルにあります。ここでは、props と state を取得して変換し、画面に表示したい JSX を返します。レンダリングコードは純粋でなければなりません。数学の公式のように、結果を計算するだけで、それ以外のことは何もすべきではありません。

  • イベントハンドラインタラクティビティの追加で紹介)は、計算するだけでなく、何らかの処理を行う、コンポーネント内のネストされた関数です。イベントハンドラは、入力フィールドを更新したり、製品を購入するために HTTP POST リクエストを送信したり、ユーザーを別の画面に移動したりすることがあります。イベントハンドラには、特定のユーザーアクション(ボタンのクリックや入力など)によって引き起こされる「副作用」(プログラムの状態を変化させる)が含まれます。

しかし、これだけでは不十分な場合があります。画面に表示されるたびにチャットサーバーに接続する必要がある ChatRoom コンポーネントについて考えてみましょう。サーバーへの接続は純粋な計算ではなく(副作用です)、レンダリング中に実行することはできません。しかし、ChatRoom が表示される原因となる、クリックのような特定のイベントはありません。

エフェクトを使用すると、特定のイベントではなく、レンダリング自体によって引き起こされる副作用を指定できます。チャットでのメッセージの送信は、ユーザーが特定のボタンをクリックすることによって直接引き起こされるため、イベントです。しかし、サーバー接続のセットアップは、コンポーネントが表示される原因となったインタラクションに関係なく発生する必要があるため、エフェクトです。エフェクトは、画面の更新後のコミットの最後に実行されます。これは、React コンポーネントを外部システム(ネットワークやサードパーティライブラリなど)と同期するのに適したタイミングです。

注意

このテキストおよび今後のテキストでは、大文字の「エフェクト」は、上記の React 固有の定義、つまりレンダリングによって引き起こされる副作用を指します。より広範なプログラミングの概念を指す場合は、「副作用」と記述します。

エフェクトが必要ないかもしれない

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

エフェクトの書き方

エフェクトを記述するには、次の 3 つの手順に従ってください

  1. エフェクトを宣言します。デフォルトでは、エフェクトはすべてのコミットの後に実行されます。
  2. エフェクトの依存関係を指定します。ほとんどのエフェクトは、すべてのレンダリング後ではなく、必要な場合にのみ再実行する必要があります。たとえば、フェードインアニメーションは、コンポーネントが表示されたときにのみトリガーする必要があります。チャットルームへの接続と切断は、コンポーネントが表示および非表示になったとき、またはチャットルームが変更されたときにのみ発生する必要があります。これを制御する方法は、依存関係を指定することで学習します。
  3. 必要に応じてクリーンアップを追加します。 一部の Effect では、停止、取り消し、または実行していた処理のクリーンアップ方法を指定する必要があります。たとえば、「接続」には「切断」、「サブスクライブ」には「サブスクライブ解除」、「フェッチ」には「キャンセル」または「無視」が必要です。クリーンアップ関数を返すことで、これを実行する方法を学びます。

これらの各ステップについて詳しく見ていきましょう。

ステップ 1: Effect を宣言する

コンポーネントで Effect を宣言するには、React からuseEffect Hookをインポートします。

import { useEffect } from 'react';

次に、コンポーネントのトップレベルでそれを呼び出し、Effect の中にコードを記述します。

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

コンポーネントがレンダリングされるたびに、React は画面を更新し、その後 useEffect 内のコードを実行します。言い換えれば、useEffect は、そのレンダリングが画面に反映されるまでコードの実行を「遅延」させます。

Effect を使用して外部システムと同期する方法を見てみましょう。<VideoPlayer> という React コンポーネントがあるとします。 isPlaying プロパティを渡すことで、再生中か一時停止かを制御できると便利です。

<VideoPlayer isPlaying={isPlaying} />;

カスタムの VideoPlayer コンポーネントは、組み込みのブラウザ <video> タグをレンダリングします。

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

ただし、ブラウザの <video> タグには、isPlaying プロパティはありません。これを制御する唯一の方法は、DOM 要素でplay() メソッドとpause() メソッドを手動で呼び出すことです。 動画が現在再生されているはずかどうかを示す isPlaying プロパティの値と、play()pause() などの呼び出しを同期する必要があります。

最初に、<video> DOM ノードへの ref を取得する必要があります。

レンダリング中に play() または pause() を呼び出そうとするかもしれませんが、それは正しくありません。

import { useState, useRef, useEffect } from 'react';

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

  if (isPlaying) {
    ref.current.play();  // Calling these while rendering isn't allowed.
  } else {
    ref.current.pause(); // Also, this crashes.
  }

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

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

このコードが正しくない理由は、レンダリング中に DOM ノードで何かを実行しようとしているためです。React では、レンダリングは JSX の純粋な計算である必要があり、DOM の変更などの副作用を含めるべきではありません。

さらに、VideoPlayer が最初に呼び出されるとき、その DOM はまだ存在しません!JSX を返すまで、React はどの DOM を作成するかを知らないため、play() または pause() を呼び出す DOM ノードはまだ存在しません。

ここでの解決策は、副作用を useEffect でラップして、レンダリング計算から外すことです。

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 />;
}

DOM の更新を Effect でラップすることにより、React が最初に画面を更新できるようになります。その後、Effect が実行されます。

VideoPlayer コンポーネントがレンダリングされる(初回または再レンダリングの場合)と、いくつかのことが起こります。まず、React は画面を更新し、<video> タグが正しいプロパティで DOM に存在するようにします。次に、React は Effect を実行します。最後に、Effect は isPlaying の値に応じて play() または pause() を呼び出します。

「再生/一時停止」を複数回押して、ビデオプレーヤーが isPlaying 値と同期していることを確認してください。

import { useState, useRef, useEffect } 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 />;
}

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  return (
    <>
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

この例では、React の状態と同期した「外部システム」は、ブラウザのメディア API でした。同様のアプローチを使用して、(jQuery プラグインなどの)レガシーな非 React コードを宣言的な React コンポーネントにラップできます。

実際には、ビデオプレーヤーの制御ははるかに複雑であることに注意してください。 play() の呼び出しが失敗したり、ユーザーが組み込みのブラウザコントロールを使用して再生または一時停止したりする可能性があります。この例は非常に単純化されており、不完全です。

落とし穴

デフォルトでは、Effect はすべてのレンダリング後に実行されます。このため、次のようなコードは無限ループを引き起こします。

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

Effect は、レンダリングの結果として実行されます。状態の設定は、レンダリングをトリガーします。Effect ですぐに状態を設定することは、コンセントをそれ自体に差し込むようなものです。Effect が実行され、状態が設定され、再レンダリングが発生し、Effect が実行され、状態が再度設定され、これにより別の再レンダリングが発生します。

Effect は通常、コンポーネントを外部システムと同期する必要があります。外部システムがなく、他の状態に基づいて状態を調整するだけの場合は、Effect が必要ない場合があります。

ステップ 2: Effect の依存関係を指定する

デフォルトでは、Effect はすべてのレンダリング後に実行されます。多くの場合、これは意図した動作ではありません。

  • 場合によっては、遅くなることがあります。外部システムとの同期は必ずしも瞬時に完了するとは限らないため、必要な場合を除いて同期をスキップしたい場合があります。たとえば、キーストロークごとにチャットサーバーに再接続したくはありません。
  • 場合によっては、間違っていることもあります。たとえば、キーストロークごとにコンポーネントのフェードインアニメーションをトリガーしたくはありません。アニメーションは、コンポーネントが最初に表示されるときにのみ 1 回再生する必要があります。

この問題を説明するために、いくつかの console.log 呼び出しと、親コンポーネントの状態を更新するテキスト入力を含む前の例を以下に示します。入力すると Effect が再実行されることに注目してください。

import { useState, useRef, useEffect } from 'react';

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

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  });

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

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

useEffect 呼び出しの 2 番目の引数として、依存関係の配列を指定することで、React に不必要に Effect を再実行しないように指示できます。上記の例の 14 行目に空の [] 配列を追加することから始めます。

useEffect(() => {
// ...
}, []);

React Hook useEffect has a missing dependency: 'isPlaying' というエラーが表示されるはずです。

import { useState, useRef, useEffect } from 'react';

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

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, []); // This causes an error

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

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

問題は、Effect内のコードが、何をするかを決定するためにisPlayingプロップに依存しているにもかかわらず、この依存関係が明示的に宣言されていないことです。この問題を解決するには、依存関係配列にisPlayingを追加してください。

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

これで、すべての依存関係が宣言されたため、エラーは発生しません。[isPlaying]を依存関係配列として指定すると、Reactは、isPlayingが前のレンダー時と同じであれば、Effectの再実行をスキップするように指示されます。この変更により、入力欄への入力ではEffectは再実行されませんが、再生/一時停止ボタンを押すと再実行されます。

import { useState, useRef, useEffect } from 'react';

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

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, [isPlaying]);

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

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

依存関係配列には、複数の依存関係を含めることができます。Reactは、指定した依存関係のすべてが、前のレンダー時とまったく同じ値の場合にのみ、Effectの再実行をスキップします。Reactは、Object.is比較を使用して依存関係の値を比較します。詳細については、useEffectリファレンスを参照してください。

依存関係を「選択」することはできません。指定した依存関係が、Effect内のコードに基づいてReactが期待するものと一致しない場合、lintエラーが発生します。これは、コード内の多くのバグを捕捉するのに役立ちます。コードの再実行を避けたい場合は、Effectコード自体を編集し、その依存関係を「必要としない」ようにしてください。

落とし穴

依存関係配列がない場合と、[]依存関係配列がある場合では、動作が異なります。

useEffect(() => {
// This runs after every render
});

useEffect(() => {
// This runs only on mount (when the component appears)
}, []);

useEffect(() => {
// This runs on mount *and also* if either a or b have changed since the last render
}, [a, b]);

次のステップで、「マウント」の意味を詳しく見ていきます。

深掘り

なぜrefは依存関係配列から省略されたのでしょうか?

このEffectはrefisPlaying両方を使用していますが、依存関係として宣言されているのはisPlayingのみです。

function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);

これは、refオブジェクトが安定した同一性を持っているためです。Reactは、すべてのレンダーで同じuseRef呼び出しから常に同じオブジェクトを取得することを保証します。決して変更されないため、それ自体でEffectが再実行されることはありません。したがって、含めるかどうかは関係ありません。含めても問題ありません。

function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying, ref]);

useStateによって返されるset関数も安定した同一性を持つため、依存関係から省略されることがよくあります。リンターがエラーなしで依存関係を省略できる場合は、省略しても安全です。

常に安定した依存関係の省略は、リンターがオブジェクトが安定していることを「認識」できる場合にのみ機能します。たとえば、refが親コンポーネントから渡された場合、依存関係配列で指定する必要があります。ただし、親コンポーネントが常に同じrefを渡すのか、複数のrefを条件付きで渡すのかを知ることはできないため、これは良いことです。したがって、Effectは、どのrefが渡されるかに依存することになります。

ステップ3:必要に応じてクリーンアップを追加する

別の例を考えてみましょう。表示されたときにチャットサーバーに接続する必要があるChatRoomコンポーネントを作成しているとします。connect()メソッドとdisconnect()メソッドを持つオブジェクトを返すcreateConnection() APIが与えられています。コンポーネントがユーザーに表示されている間、どのようにしてコンポーネントを接続状態に保ちますか?

まず、Effectロジックを記述します。

useEffect(() => {
const connection = createConnection();
connection.connect();
});

再レンダーのたびにチャットに接続するのは時間がかかるため、依存関係配列を追加します。

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

Effect内のコードはプロップや状態を使用しないため、依存関係配列は[](空)になります。これにより、Reactはコンポーネントが「マウント」されたとき、つまり、画面に初めて表示されたときにのみこのコードを実行するように指示されます。

このコードを実行してみましょう。

import { useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
  }, []);
  return <h1>Welcome to the chat!</h1>;
}

このEffectはマウント時にのみ実行されるため、コンソールに"✅ Connecting..."が1回出力されると予想されるかもしれません。ただし、コンソールを確認すると、"✅ Connecting..."が2回出力されます。なぜこのようなことが起こるのでしょうか?

ChatRoomコンポーネントが、多くの異なる画面を持つ大規模なアプリの一部であると想像してください。ユーザーは、ChatRoomページで旅を始めます。コンポーネントがマウントされ、connection.connect()が呼び出されます。次に、ユーザーが別の画面(たとえば、設定ページ)に移動すると想像してください。ChatRoomコンポーネントがアンマウントされます。最後に、ユーザーが「戻る」をクリックすると、ChatRoomが再びマウントされます。これにより、2番目の接続が確立されますが、最初の接続は破棄されていません!ユーザーがアプリ内を移動すると、接続が積み重なり続けることになります。

このようなバグは、広範な手動テストなしでは見落としがちです。それらをすばやく見つけるのに役立つように、開発環境ではReactは初期マウントの直後にすべてのコンポーネントを1回再マウントします。

"✅ Connecting..."ログが2回表示されることで、実際の問題に気づくことができます。コードがコンポーネントのアンマウント時に接続を閉じないということです。

この問題を解決するには、Effectからクリーンアップ関数を返します。

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

Reactは、Effectが再度実行されるたびにその直前と、コンポーネントがアンマウント(削除)されるときに最後に、クリーンアップ関数を呼び出します。クリーンアップ関数が実装されたときに何が起こるかを見てみましょう。

import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';

export default function ChatRoom() {
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, []);
  return <h1>Welcome to the chat!</h1>;
}

これで、開発環境で3つのコンソールログが表示されます。

  1. "✅ Connecting..."
  2. "❌ Disconnected."
  3. "✅ Connecting..."

これは開発環境での正しい動作です。コンポーネントを再マウントすることにより、Reactは、移動してから戻った場合にコードが壊れないことを検証します。切断してから再度接続することは、まさに起こるべきことです!クリーンアップを適切に実装すると、Effectを1回実行した場合と、実行、クリーンアップ、再実行した場合で、ユーザーに見える違いはないはずです。Reactが開発環境でバグがないかコードをプローブしているため、余分な接続/切断呼び出しペアがあります。これは正常です。それをなくそうとしないでください!

本番環境では、"✅ Connecting..."が1回だけ出力されるのを確認できます。コンポーネントの再マウントは、クリーンアップが必要なEffectを見つけるのに役立つように、開発環境でのみ発生します。開発動作をオプトアウトするために、Strict Modeをオフにできますが、オンにしておくことをお勧めします。これにより、上記のような多くのバグを見つけることができます。

開発環境で Effect が 2 回発火する場合の対処方法

React は、最後の例のようなバグを見つけるために、開発環境で意図的にコンポーネントを再マウントします。正しい質問は、「Effect を一度だけ実行する方法」ではなく、「再マウント後も動作するように Effect を修正する方法」です。

通常、答えはクリーンアップ関数を実装することです。クリーンアップ関数は、Effect が行っていたことを停止または取り消す必要があります。経験則として、ユーザーは、(本番環境のように)Effect が一度だけ実行される場合と、(開発環境で見るように)セットアップ → クリーンアップ → セットアップ のシーケンスの場合を区別できないはずです。

記述する Effect のほとんどは、以下の一般的なパターンのいずれかに当てはまります。

落とし穴

Effect の発火を防ぐために ref を使用しない

開発環境で Effect が 2 回発火するのを防ぐためのよくある落とし穴は、ref を使用して Effect が複数回実行されないようにすることです。たとえば、上記のバグを useRef で「修正」できます。

const connectionRef = useRef(null);
useEffect(() => {
// 🚩 This wont fix the bug!!!
if (!connectionRef.current) {
connectionRef.current = createConnection();
connectionRef.current.connect();
}
}, []);

これにより、開発環境で "✅ Connecting..." が 1 回だけ表示されるようになりますが、バグは修正されません。

ユーザーが離れると、接続は閉じられたままになり、戻ってきたときに新しい接続が作成されます。ユーザーがアプリ内を移動すると、「修正」前と同じように、接続がどんどん積み重なってしまいます。

バグを修正するには、Effect を 1 回だけ実行するだけでは不十分です。Effect は再マウント後も動作する必要があるため、上記の解決策のように接続をクリーンアップする必要があります。

一般的なパターンの処理方法については、以下の例を参照してください。

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

React で記述されていない UI ウィジェットを追加する必要がある場合があります。たとえば、ページに地図コンポーネントを追加するとします。これには setZoomLevel() メソッドがあり、React コードの zoomLevel 状態変数とズームレベルを同期させたいとします。Effect は次のようになります。

useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

この場合、クリーンアップは不要であることに注意してください。開発環境では、React は Effect を 2 回呼び出しますが、これは問題ではありません。同じ値で setZoomLevel を 2 回呼び出しても何も起こらないからです。少し遅くなる可能性がありますが、本番環境では不必要に再マウントされないため、これは問題ではありません。

一部の API では、連続して 2 回呼び出すことが許可されていない場合があります。たとえば、組み込みの showModal メソッドは、<dialog> 要素で、2 回呼び出すとスローします。クリーンアップ関数を実装して、ダイアログを閉じます。

useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);

開発環境では、Effect は showModal() を呼び出し、すぐに close() を呼び出し、再度 showModal() を呼び出します。これは、本番環境で表示されるように、showModal() を 1 回呼び出すのと同じユーザーに表示される動作です。

イベントのサブスクライブ

Effect が何かにサブスクライブする場合、クリーンアップ関数はサブスクライブを解除する必要があります。

useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);

開発環境では、Effect は addEventListener() を呼び出し、すぐに removeEventListener() を呼び出し、同じハンドラーで再度 addEventListener() を呼び出します。そのため、一度にアクティブなサブスクリプションは 1 つだけになります。これは、本番環境のように、addEventListener() を 1 回呼び出すのと同じユーザーに表示される動作です。

アニメーションのトリガー

Effect が何かをアニメーションで表示する場合、クリーンアップ関数はアニメーションを初期値にリセットする必要があります。

useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // Trigger the animation
return () => {
node.style.opacity = 0; // Reset to the initial value
};
}, []);

開発環境では、不透明度が 1 に設定され、次に 0 に設定され、次に再度 1 に設定されます。これは、本番環境で起こるように、直接 1 に設定するのと同じユーザーに表示される動作である必要があります。トゥイーンをサポートするサードパーティのアニメーションライブラリを使用する場合、クリーンアップ関数はタイムラインを初期状態にリセットする必要があります。

データのフェッチ

Effect が何かをフェッチする場合、クリーンアップ関数は、フェッチを中止するか、その結果を無視する必要があります。

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

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

startFetching();

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

既に発生したネットワークリクエストを「取り消す」ことはできませんが、クリーンアップ関数は、もはや関連性のないフェッチがアプリケーションに影響を与え続けないようにする必要があります。もしuserId'Alice'から'Bob'に変更された場合、クリーンアップは、'Alice'のレスポンスが'Bob'のレスポンスより後に到着した場合でも無視されるようにします。

開発環境では、ネットワークタブに2つのフェッチが表示されます。 これは問題ありません。上記のアプローチでは、最初の Effect はすぐにクリーンアップされるため、ignore変数のコピーはtrueに設定されます。したがって、余分なリクエストがあっても、if (!ignore)チェックのおかげで状態に影響を与えることはありません。

本番環境では、リクエストは1つだけになります。 開発環境での2番目のリクエストが気になる場合は、リクエストを重複排除し、コンポーネント間でレスポンスをキャッシュするソリューションを使用するのが最善のアプローチです。

function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...

これにより、開発エクスペリエンスが向上するだけでなく、アプリケーションの動作がより速く感じられるようになります。たとえば、ユーザーが[戻る]ボタンを押したときに、キャッシュされるため、一部のデータが再度ロードされるのを待つ必要がなくなります。このようなキャッシュを自分で構築することも、Effectでの手動フェッチの多くの代替手段のいずれかを使用することもできます。

深掘り

Effectでのデータフェッチの良い代替手段は何ですか?

Effect内でfetch呼び出しを記述することは、特に完全なクライアントサイドアプリではデータをフェッチする一般的な方法です。ただし、これは非常に手動的なアプローチであり、重大な欠点があります。

  • Effectはサーバー上で実行されません。 つまり、初期サーバーレンダリングされたHTMLには、データのないローディング状態のみが含まれます。クライアントコンピュータは、すべてのJavaScriptをダウンロードしてアプリをレンダリングし、その結果、データをロードする必要があることを発見する必要があります。これはあまり効率的ではありません。
  • Effectで直接フェッチすると、「ネットワークウォーターフォール」を簡単に作成できます。 親コンポーネントをレンダリングし、データをフェッチし、子コンポーネントをレンダリングしてから、子コンポーネントがデータのフェッチを開始します。ネットワークが高速でない場合、これはすべてのデータを並行してフェッチするよりも大幅に遅くなります。
  • 通常、Effectで直接フェッチするということは、データをプリロードまたはキャッシュしないことを意味します。 たとえば、コンポーネントがアンマウントしてから再度マウントする場合、再度データをフェッチする必要があります。
  • あまり人間工学的ではありません。 競合状態のようなバグが発生しないようにfetch呼び出しを記述する場合、かなりのボイラープレートコードが含まれます。

この欠点のリストは、Reactに固有のものではありません。これは、任意のライブラリでのマウント時のデータフェッチに適用されます。ルーティングと同様に、データフェッチをうまく行うのは簡単ではないため、次のアプローチをお勧めします。

  • フレームワークを使用する場合は、組み込みのデータフェッチメカニズムを使用してください。 最新のReactフレームワークには、効率的で上記の落とし穴が発生しないデータフェッチメカニズムが統合されています。
  • それ以外の場合は、クライアントサイドキャッシュの使用または構築を検討してください。 一般的なオープンソースソリューションには、React QueryuseSWR、およびReact Router 6.4+などがあります。独自のソリューションを構築することもできます。その場合、内部的にはEffectを使用しますが、リクエストの重複排除、レスポンスのキャッシュ、および(データのプリロードまたはルートへのデータ要件の昇格によって)ネットワークウォーターフォールの回避のロジックを追加します。

これらのアプローチのいずれも自分に合わない場合は、Effectで直接データのフェッチを続行できます。

アナリティクスを送信する

ページ訪問時にアナリティクスイベントを送信する次のコードを検討してください。

useEffect(() => {
logVisit(url); // Sends a POST request
}, [url]);

開発環境では、すべてのURLに対してlogVisitが2回呼び出されるため、それを修正しようとするかもしれません。このコードはそのままにしておくことをお勧めします。 前の例と同様に、1回実行した場合と2回実行した場合の間に、ユーザーに表示される動作の違いはありません。実用的な観点から、開発マシンからのログが本番環境のメトリクスを歪めることを避けるため、開発環境ではlogVisitは何もすべきではありません。コンポーネントはファイルを保存するたびに再マウントされるため、開発環境では追加の訪問を記録します。

本番環境では、重複した訪問ログはありません。

送信しているアナリティクスイベントをデバッグするには、アプリをステージング環境(本番モードで実行)にデプロイするか、厳格モードとその開発専用の再マウントチェックを一時的にオプトアウトできます。また、Effectの代わりにルート変更イベントハンドラーからアナリティクスを送信することもできます。より正確な分析のために、インターセクションオブザーバーは、どのコンポーネントがビューポート内にあるか、およびそれらがどれだけ長く表示されているかを追跡するのに役立ちます。

Effectではない:アプリケーションの初期化

一部のロジックは、アプリケーションの起動時に一度だけ実行する必要があります。コンポーネントの外部に配置できます。

if (typeof window !== 'undefined') { // Check if we're running in the browser.
checkAuthToken();
loadDataFromLocalStorage();
}

function App() {
// ...
}

これにより、ブラウザがページをロードした後に、そのようなロジックが一度だけ実行されることが保証されます。

Effectではない:製品の購入

クリーンアップ関数を記述しても、Effectを2回実行することによるユーザーに表示される結果を防ぐ方法がない場合があります。たとえば、Effectが製品の購入のようなPOSTリクエストを送信する場合があります。

useEffect(() => {
// 🔴 Wrong: This Effect fires twice in development, exposing a problem in the code.
fetch('/api/buy', { method: 'POST' });
}, []);

製品を2回購入したくはないでしょう。ただし、これはこのロジックをEffectに配置すべきではない理由でもあります。ユーザーが別のページに移動して[戻る]ボタンを押すとどうなるでしょうか? Effectは再び実行されます。ユーザーがページを訪問したときに製品を購入するのではなく、ユーザーが[購入]ボタンをクリックしたときに購入したいのです。

購入はレンダリングによって発生するのではなく、特定のインタラクションによって発生します。ユーザーがボタンを押したときにのみ実行する必要があります。Effectを削除して、/api/buyリクエストを購入ボタンのイベントハンドラーに移動します:

function handleClick() {
// ✅ Buying is an event because it is caused by a particular interaction.
fetch('/api/buy', { method: 'POST' });
}

これは、再マウントがアプリケーションのロジックを壊す場合、通常は既存のバグが明らかになることを示しています。 ユーザーの視点から見ると、ページを訪れることは、そのページを訪れてリンクをクリックし、その後[戻る]ボタンを押して再びページを表示することと変わらないはずです。Reactは、開発環境で一度コンポーネントを再マウントすることで、コンポーネントがこの原則に従っていることを検証します。

まとめ

このプレイグラウンドは、Effectsが実際にどのように機能するかを「感じ」るのに役立ちます。

この例では、setTimeoutを使用して、Effectの実行後3秒後にコンソールログに入力テキストが表示されるようにスケジュールします。クリーンアップ関数は、保留中のタイムアウトをキャンセルします。「コンポーネントのマウント」を押して開始してください。

import { useState, useEffect } from 'react';

function Playground() {
  const [text, setText] = useState('a');

  useEffect(() => {
    function onTimeout() {
      console.log('⏰ ' + text);
    }

    console.log('🔵 Schedule "' + text + '" log');
    const timeoutId = setTimeout(onTimeout, 3000);

    return () => {
      console.log('🟡 Cancel "' + text + '" log');
      clearTimeout(timeoutId);
    };
  }, [text]);

  return (
    <>
      <label>
        What to log:{' '}
        <input
          value={text}
          onChange={e => setText(e.target.value)}
        />
      </label>
      <h1>{text}</h1>
    </>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Unmount' : 'Mount'} the component
      </button>
      {show && <hr />}
      {show && <Playground />}
    </>
  );
}

最初は3つのログが表示されます。 "a" log をスケジュール"a" log をキャンセル、そして再び"a" log をスケジュール。3秒後には、aと表示されるログも表示されます。前述したように、余分なスケジュール/キャンセルペアは、クリーンアップを適切に実装したことを確認するために、Reactが開発環境でコンポーネントを一度再マウントするためです。

次に、入力をabcに変更します。十分な速さで変更すると、"ab" log をスケジュールの直後に"ab" log をキャンセル、そして"abc" log をスケジュールが表示されます。Reactは、次のレンダリングのEffectの前に、常に前のレンダリングのEffectをクリーンアップします。このため、入力に高速で入力しても、一度にスケジュールされるタイムアウトは最大でも1つです。入力を数回編集して、Effectsがどのようにクリーンアップされるかコンソールで確認してください。

何かを入力してから、すぐに「コンポーネントのマウント解除」を押してください。マウント解除が最後のレンダリングのEffectをどのようにクリーンアップするかを確認してください。ここでは、最後のタイムアウトが発火する前にクリアされます。

最後に、上記のコンポーネントを編集し、タイムアウトがキャンセルされないように、クリーンアップ関数をコメントアウトしてください。abcdeを高速で入力してみてください。3秒後に何が起こると思いますか?タイムアウト内のconsole.log(text)は、最新のtextを出力し、5つのabcdeログを生成するでしょうか?試して、直感を確かめてみてください!

3秒後には、5つのabcdeログではなく、一連のログ(aababcabcdabcde)が表示されるはずです。各Effectは、対応するレンダリングからのtextの値を「キャプチャ」します。textの状態が変更されたことは問題ではありません。text = 'ab'のレンダリングからのEffectは、常に'ab'を表示します。言い換えれば、各レンダリングからのEffectは互いに分離されています。これがどのように機能するのか興味がある場合は、クロージャについて読んでみてください。

深掘り

各レンダリングには独自のEffectsがあります

useEffectは、レンダリング出力に動作の一部を「アタッチ」するものと考えることができます。このEffectを考えてみましょう。

export default function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);

return <h1>Welcome to {roomId}!</h1>;
}

ユーザーがアプリ内を移動するときに正確に何が起こるかを見てみましょう。

初期レンダリング

ユーザーが<ChatRoom roomId="general" />を訪れます。頭の中でroomId'general'に置き換えてみましょう。

// JSX for the first render (roomId = "general")
return <h1>Welcome to general!</h1>;

Effectは、またレンダリング出力の一部です。最初のレンダリングのEffectは次のようになります。

// Effect for the first render (roomId = "general")
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// Dependencies for the first render (roomId = "general")
['general']

ReactはこのEffectを実行し、'general'チャットルームに接続します。

同じ依存関係での再レンダリング

<ChatRoom roomId="general" />が再レンダリングされるとします。JSXの出力は同じです。

// JSX for the second render (roomId = "general")
return <h1>Welcome to general!</h1>;

Reactはレンダリング出力が変更されていないことを認識するため、DOMを更新しません。

2回目のレンダリングからのEffectは次のようになります。

// Effect for the second render (roomId = "general")
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// Dependencies for the second render (roomId = "general")
['general']

Reactは、2回目のレンダリングからの['general']を、最初のレンダリングからの['general']と比較します。すべての依存関係が同じであるため、Reactは2回目のレンダリングからのEffectを無視します。呼び出されることはありません。

異なる依存関係での再レンダリング

次に、ユーザーは<ChatRoom roomId="travel" />を訪れます。今回は、コンポーネントは異なるJSXを返します。

// JSX for the third render (roomId = "travel")
return <h1>Welcome to travel!</h1>;

Reactは、DOMを更新して"Welcome to general""Welcome to travel"に変更します。

3回目のレンダリングからのEffectは次のようになります。

// Effect for the third render (roomId = "travel")
() => {
const connection = createConnection('travel');
connection.connect();
return () => connection.disconnect();
},
// Dependencies for the third render (roomId = "travel")
['travel']

Reactは、3回目のレンダリングからの['travel']を、2回目のレンダリングからの['general']と比較します。1つの依存関係が異なります。Object.is('travel', 'general')falseです。Effectはスキップできません。

Reactが3回目のレンダーからEffectを適用する前に、実行された最後のEffectをクリーンアップする必要があります。2回目のレンダーのEffectはスキップされたため、Reactは1回目のレンダーのEffectをクリーンアップする必要があります。最初のレンダーまでスクロールすると、1回目のレンダーのクリーンアップが、createConnection('general')で作成された接続でdisconnect()を呼び出していることがわかります。これにより、アプリは'general'チャットルームから切断されます。

その後、Reactは3回目のレンダーのEffectを実行します。これは'travel'チャットルームに接続します。

アンマウント

最後に、ユーザーが移動し、ChatRoomコンポーネントがアンマウントするとします。Reactは最後のEffectのクリーンアップ関数を実行します。最後のEffectは3回目のレンダーからのものでした。3回目のレンダーのクリーンアップは、createConnection('travel')接続を破棄します。したがって、アプリは'travel'ルームから切断されます。

開発専用の動作

厳格モードがオンの場合、Reactはマウント後(状態とDOMは保持されます)にすべてのコンポーネントを1回再マウントします。これはクリーンアップが必要なEffectを見つけ、競合状態などのバグを早期に発見するのに役立ちます。さらに、開発中にファイルを保存するたびに、ReactはEffectを再マウントします。これらの動作はどちらも開発専用です。

まとめ

  • イベントとは異なり、Effectは特定のインタラクションではなく、レンダリング自体によって引き起こされます。
  • Effectを使用すると、コンポーネントを外部システム(サードパーティAPI、ネットワークなど)と同期させることができます。
  • デフォルトでは、Effectはすべてのレンダー後(最初のレンダーを含む)に実行されます。
  • Reactは、Effectの依存関係がすべて、最後のレンダー時と同じ値である場合、Effectをスキップします。
  • 依存関係を「選択」することはできません。それらは、Effect内のコードによって決定されます。
  • 空の依存関係配列([])は、コンポーネントが「マウント」、つまり画面に追加されることに対応します。
  • 厳格モードでは、ReactはEffectをストレステストするために、(開発時のみ!)コンポーネントを2回マウントします。
  • Effectが再マウントのために壊れている場合は、クリーンアップ関数を実装する必要があります。
  • Reactは、Effectが次に実行される前、およびアンマウント中にクリーンアップ関数を呼び出します。

チャレンジ 1 4:
マウント時にフィールドにフォーカスを当てる

この例では、フォームは<MyInput />コンポーネントをレンダリングします。

入力のfocus()メソッドを使用して、MyInputが画面に表示されたときに自動的にフォーカスするようにします。コメントアウトされた実装がすでにありますが、うまくいきません。なぜうまくいかないのかを理解し、修正してください。(autoFocus属性に詳しい場合は、それが存在しないふりをしてください。同じ機能をゼロから再実装しています。)

import { useEffect, useRef } from 'react';

export default function MyInput({ value, onChange }) {
  const ref = useRef(null);

  // TODO: This doesn't quite work. Fix it.
  // ref.current.focus()    

  return (
    <input
      ref={ref}
      value={value}
      onChange={onChange}
    />
  );
}

ソリューションが機能することを確認するには、「フォームを表示」を押して、入力がフォーカス(強調表示され、カーソルが内部に配置されます)されることを確認します。「フォームを非表示」にしてから、もう一度「フォームを表示」を押します。入力が再び強調表示されていることを確認します。

MyInputは、すべてのレンダー後ではなく、マウント時にのみフォーカスする必要があります。動作が正しいことを確認するには、「フォームを表示」を押してから、「大文字にする」チェックボックスを繰り返し押します。チェックボックスをクリックしても、上の入力にフォーカスが当たらないはずです。