リアクティブなエフェクトのライフサイクル

エフェクトはコンポーネントとは異なるライフサイクルを持ちます。コンポーネントは、マウント、更新、アンマウントを行うことができます。エフェクトは 2 つのことしかできません。同期の開始と、同期の停止です。エフェクトが props や state に依存し、これらが時間と共に変化する場合、このサイクルは繰り返し発生します。React では、エフェクトの依存配列が適切に指定されているかをチェックするリンタルールが提供されています。これにより、エフェクトが最新の props や state と同期された状態を維持することができます。

このページで学ぶこと

  • エフェクトのライフサイクルとコンポーネントのライフサイクルの違い
  • 個々のエフェクトを独立して考える方法
  • いつ、そしてなぜエフェクトを再同期する必要があるのか
  • エフェクトの依存配列を決める方法
  • 値がリアクティブであるとはどのような意味か
  • 空の依存配列の意味
  • React のリンタは、どのようにして依存配列が正しいと判断するのか
  • リンタに同意できない時にどうすれば良いか

エフェクトのライフサイクル

すべての React コンポーネントは同じライフサイクルを持ちます。

  • 画面に追加されたとき、コンポーネントはマウントされます。
  • (大抵はインタラクションに応じて)新しい props や state を受け取ったとき、コンポーネントは更新されます。
  • 画面から削除されたとき、コンポーネントはアンマウントされます。

これは、コンポーネントの考え方としては良いですが、エフェクトの考え方としては良くありません。それぞれのエフェクトは、コンポーネントのライフサイクルから独立させて考えましょう。エフェクトは、現在の props や state に外部システムをどのように同期させるのかを記述します。コードが変更されれば、同期の頻度も増減するでしょう。

この点を説明するために、コンポーネントをチャットサーバに接続するエフェクトを考えてみましょう。

const serverUrl = 'https://localhost:1234';

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

エフェクトの本体には、どのように同期を開始するのかを記述しています。

// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
// ...

エフェクトの返り値として表されるクリーンアップ関数には、どのように同期を停止するのかを記述しています。

// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
// ...

これだけを見ると、React はコンポーネントのマウント時に同期を開始し、アンマウント時に同期を停止するだけのように見えます。しかし、これで終わりではありません! コンポーネントがマウントされている間、同期の開始と停止を繰り返し行わなければならない場合があるからです。

この挙動がいつ発生し、どのように制御することができ、そしてそれがなぜ必要なのかを見ていきましょう。

補足

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

なぜ複数回の同期が必要なのか

ChatRoom コンポーネントが、ユーザがドロップダウンで選択した roomId プロパティを受け取ることを考えましょう。ユーザが最初に、roomId として "general" ルームを選択したとします。あなたのアプリは "general" ルームを表示します。

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId /* "general" */ }) {
// ...
return <h1>Welcome to the {roomId} room!</h1>;
}

この UI が表示されたあと、React は同期を開始するためにエフェクトを実行します。これにより、"general" ルームに通信が接続されます。

function ChatRoom({ roomId /* "general" */ }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // Connects to the "general" room
connection.connect();
return () => {
connection.disconnect(); // Disconnects from the "general" room
};
}, [roomId]);
// ...

ここまでは順調です。

この後、ユーザがドロップダウンで違うルーム(例えば "travel" ルーム)を選択したとします。まず、React は UI を更新します。

function ChatRoom({ roomId /* "travel" */ }) {
// ...
return <h1>Welcome to the {roomId} room!</h1>;
}

次に何が起こるか考えてみましょう。ユーザには、UI 上では "travel" ルームが選択されているように見えています。しかし、直近に実行されたエフェクトは、未だ "general" ルームに接続しています。roomId プロパティが変化してしまったため、エフェクトが行なっていたこと("general" ルームへの接続)が UI と一致しなくなってしまいました。

この時点で、あなたは React に以下の 2 つの処理を実行してほしいはずです。

  1. 古い roomId での同期を停止する("general" ルームとの接続を切断する)
  2. 新しい roomId での同期を開始する("travel" ルームとの接続を開始する)

ラッキーなことに、これらの処理を行う方法は既に学んでいます! エフェクトの本体には「どのように同期を開始するのか」を、クリーンアップ関数には「どのように同期を停止するのか」を記述するのでした。React はエフェクトを、正しい順序、かつ正しい props と state で呼び出します。では、具体的にどうなるのか見てみましょう。

どのようにしてエフェクトの再同期が行われるのか

ChatRoom コンポーネントが、roomId プロパティとして新しい値を受け取ったところを思い出してください。プロパティの値が、"general" から "travel" に変化しました。React は、別のルームに再接続するため、エフェクトを再同期する必要があります。

まず React は、同期を停止するために "general" ルームに接続したあとにエフェクトが返したクリーンアップ関数を呼び出します。roomId は "general" であったので、クリーンアップ関数は "general" ルームの通信を切断します。

function ChatRoom({ roomId /* "general" */ }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // Connects to the "general" room
connection.connect();
return () => {
connection.disconnect(); // Disconnects from the "general" room
};
// ...

次に React は、レンダー中に提供されたエフェクトを実行します。このとき、roomId は "travel" なので、"travel" ルームへの同期が開始されます。(最終的に、そのエフェクトのクリーンアップ関数が呼び出されるまで、同期が続きます。)

function ChatRoom({ roomId /* "travel" */ }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // Connects to the "travel" room
connection.connect();
// ...

これで、ユーザが UI で選択したルームに正しく接続することができました。一件落着!

roomId が変化しコンポーネントが再レンダーされるたびに、エフェクトは再同期を行います。例として、ユーザが roomId を "travel" から "music" に変更したとしましょう。React は、クリーンアップ関数を呼び出すことで、再度エフェクトの同期を停止("travel" ルームを切断)します。次に、新しい roomId プロパティの値でエフェクトの本体を実行し、同期を開始("music" ルームに接続)します。

最後に、ユーザが別の画面に遷移すれば、ChatRoom コンポーネントはアンマウントされます。これ以上接続を維持する必要はないので、React は最後にもう一度同期を停止し、"music" ルームとの通信を切断します。

エフェクトの視点で考える

ChatRoom コンポーネントの視点で、起こったことを振り返りましょう。

  1. roomId が "general" にセットされた状態で ChatRoom がマウントされる
  2. roomId が "travel" にセットされた状態で ChatRoom が更新される
  3. roomId が "music" にセットされた状態で ChatRoom が更新される
  4. ChatRoom がアンマウントされる

この、コンポーネントのライフサイクルの各ポイントで、エフェクトは以下の処理を行いました。

  1. エフェクトが "general" ルームに接続する
  2. エフェクトが "general" ルームを切断し、"travel" ルームに接続する
  3. エフェクトが "travel" ルームを切断し、"music" ルームに接続する
  4. エフェクトが "music" ルームを切断する

では、エフェクトの視点でどのようなことが発生したのか考えましょう。

useEffect(() => {
// エフェクトが、roomId で指定されたルームに接続する
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
// ...切断されるまで
connection.disconnect();
};
}, [roomId]);

このコードの構造から、何が発生したのかを、複数に区切られた時間の連続として理解できるかもしれません。

  1. エフェクトが、"general" に接続する(切断されるまで)
  2. エフェクトが、"travel" に接続する(切断されるまで)
  3. エフェクトが、"music" に接続する(切断されるまで)

先ほどはコンポーネントの視点で考えていました。コンポーネントの視点から見ると、エフェクトは、“レンダー直後” や “アンマウント直前” のように特定のタイミングで発生する “コールバック関数” や “ライフサイクル中のイベント” であると考えたくなります。しかし、このような考え方はすぐにややこしくなるため、避けた方が無難です。

その代わりに、エフェクトの開始/終了という 1 サイクルのみにフォーカスしてください。コンポーネントがマウント中なのか、更新中なのか、はたまたアンマウント中なのかは問題ではありません。どのように同期を開始し、どのように同期を終了するのか、これを記述すれば良いのです。このことを意識するだけで、開始・終了が何度も繰り返されても、柔軟に対応できるエフェクトとなります。

もしかすると、JSX を作成するレンダーロジックを書くときのことを思い出したかもしれません。このときも、コンポーネントがマウント中なのか、更新中なのかは意識しませんでした。あなたは画面に表示されるものを記述し、残りは React がやってくれるのです。

エフェクトが再同期できることを React はどのように確認するのか

こちらは、実際に動かして試すことができるサンプルです。“Open chat” を押して ChatRoom コンポーネントをマウントしてみましょう。

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

const serverUrl = 'https://localhost:1234';

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

export default function App() {
  const [roomId, setRoomId] = useState('general');
  const [show, setShow] = useState(false);
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <button onClick={() => setShow(!show)}>
        {show ? 'Close chat' : 'Open chat'}
      </button>
      {show && <hr />}
      {show && <ChatRoom roomId={roomId} />}
    </>
  );
}

コンポーネントが初めてマウントされたときに、3 つのログが表示されることに注目してください。

  1. ✅ Connecting to "general" room at https://localhost:1234... (development-only)
  2. ❌ Disconnected from "general" room at https://localhost:1234. (development-only)
  3. ✅ Connecting to "general" room at https://localhost:1234...

最初の 2 つのログは開発時のみ表示されます。開発時には、React は常に各コンポーネントを 1 度再マウントします。

開発時には、React はエフェクトを即座に再同期させて、エフェクトの再同期が正しく行われることを確認します。この動作は、ドアの鍵が正しくかかるか確認するために、ドアを 1 度余分に開け閉めしてみることに似ています。React は、クリーンアップ関数が正しく実装されているかを確認するために、開発時にエフェクトを 1 回余分に開始・停止します。

実環境でエフェクトの再同期が発生する主な理由は、エフェクトが利用するデータが変更されることです。上記のサンドボックスで、チャットルームの選択を変更してみてください。roomId が変更されると、エフェクトが再同期されることがわかります。

しかし、再同期が必要となるより珍しいケースもあります。例えば、チャットが開いている間に、サンドボックス上の serverUrl を編集してみてください。コードの編集に応じて、エフェクトが再同期されることがわかります。将来的には、React は再同期に依存する機能を追加するかもしれません。

React がエフェクトの再同期が必要であることを知る方法

roomId が変更された後に、React はなぜエフェクトを再同期する必要があることを知ったのでしょうか。それは、roomId を依存配列に含めることで、React にそのコードが roomId に依存していることを伝えたからです。

function ChatRoom({ roomId }) { // roomId は時間の経過とともに変化する可能性がある
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // このエフェクトは `roomId` を利用している
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]); // つまり、このエフェクトは roomId に "依存" している
// ...

仕組みは以下のようになっています。

  1. roomId はプロパティである。これは、roomId が時間の経過とともに変化する可能性があるということです。
  2. エフェクトが roomId を利用する。(これは、エフェクトのロジックが、後で変更される可能性のある値に依存していることを意味しています。)
  3. そのため、エフェクトの依存配列に roomId を指定する。(こうすることで、roomId が変更されたときに再同期することができます。)

コンポーネントが再レンダーされるたびに、React は渡された依存配列を確認します。配列内の値のいずれかが、前回のレンダー時に渡された配列の同じ場所の値と異なる場合、React はエフェクトを再同期します。

例えば、初回のレンダー時に ["general"] を渡し、次のレンダー時に ["travel"] を渡したとします。React は "general" と "travel" を比較します(Object.is で比較されます)。異なる値が存在するため、React はエフェクトを再同期します。一方、コンポーネントが再レンダーされたが roomId が変更されていない場合、エフェクトは同じルームに接続されたままになります。

1 つのエフェクトは独立した 1 つの同期の処理を表す

実装済みのエフェクトと同時に実行するというだけの理由で、関係のないロジックを同じエフェクトに追加しないでください。例えば、ユーザがルームを訪れたときにアナリティクスイベントを送信したいとします。既に roomId に依存するエフェクトがあるため、そこにアナリティクスの呼び出しを追加したくなるかもしれません。

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

しかし後になってこのエフェクトに、接続の再確立を必要とするような何か別の依存値を追加する必要が出てきたとします。このエフェクトが再同期されると、ルームが変わっていないのに logVisit(roomId) が再度呼び出されてしまいます。これは意図したものではありません。訪問の記録は、接続とは別の処理です。別のエフェクトとして記述してください。

function ChatRoom({ roomId }) {
useEffect(() => {
logVisit(roomId);
}, [roomId]);

useEffect(() => {
const connection = createConnection(serverUrl, roomId);
// ...
}, [roomId]);
// ...
}

コード内の 1 つのエフェクトは、1 つの独立した同期の処理を表すべきです。

上記の例では、1 つのエフェクトを削除しても、他のエフェクトのロジックは壊れません。これは、それらが異なるものを同期していることを示しており、分割するのが理にかなっているということです。逆に、1 つのロジックを別々のエフェクトに分割してしまうと、コードは一見「きれい」に見えるかもしれませんが、メンテナンスは困難になるでしょう。そのため、コードがきれいに見えるかどうかではなく、処理が独立しているか同じかを考える必要があります。

エフェクトはリアクティブ (reactive) な値に “反応” する

以下の例では、エフェクトが 2 つの変数 (serverUrl と roomId) を利用していますが、依存配列には roomId のみが指定されています。

const serverUrl = 'https://localhost:1234';

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

なぜ serverUrl は依存配列に追加しなくて良いのでしょうか?

これは、再レンダーが起こっても、決して serverUrl が変化することはないからです。どのような理由で何度再レンダーが起こっても、いつも同じ値です。したがって、依存配列に追加しても意味がありません。結局のところ、指定する依存値は、時間によって変化して初めて意味があるのです!

一方、roomId は再レンダー時に異なる値になる可能性があります。コンポーネント内で宣言された props、state、その他の値は、レンダー時に計算され、React のデータフローに含まれるため、リアクティブです。

もし serverUrl が state 変数だった場合、リアクティブとなります。リアクティブな値は、依存配列に含める必要があります。

function ChatRoom({ roomId }) { // props は時間経過とともに変化し得る
const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // state は時間経過とともに変化し得る

useEffect(() => {
const connection = createConnection(serverUrl, roomId); // エフェクトは props と state を利用している
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId, serverUrl]); // つまり、エフェクトは props と state に "依存" している
// ...
}

serverUrl を依存配列に含めることで、serverUrl が変更された後に再同期されることを保証します。

チャットルームの選択を変更したり、サーバの URL を編集してみてください。

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

function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://localhost:1234');

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

  return (
    <>
      <label>
        Server URL:{' '}
        <input
          value={serverUrl}
          onChange={e => setServerUrl(e.target.value)}
        />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}

roomId や serverUrl のようなリアクティブな値を変更するたびに、エフェクトはチャットサーバに再接続します。

依存配列が空のエフェクトは何を意味するのか

もし serverUrl や roomId をコンポーネント外に移動した場合、どうなるでしょうか?

const serverUrl = 'https://localhost:1234';
const roomId = 'general';

function ChatRoom() {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, []); // ✅ All dependencies declared
// ...
}

現在のエフェクトのコードは、リアクティブな値を利用していません。そのため、依存配列は空 ([]) になります。

空の依存配列 ([]) をコンポーネントの視点から考えると、このエフェクトは、コンポーネントのマウント時にチャットルームに接続し、アンマウント時に切断することを意味しています。(開発時は、ロジックのストレステストのために、余計に一度再同期されることをお忘れなく)

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

const serverUrl = 'https://localhost:1234';
const roomId = 'general';

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

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Close chat' : 'Open chat'}
      </button>
      {show && <hr />}
      {show && <ChatRoom />}
    </>
  );
}

しかし、エフェクトの視点から考えると、マウントとアンマウントについて考える必要は全くありません。重要なのは、エフェクトが同期の開始と停止のときに何を行うかを指定したことです。今回は、リアクティブな依存値はありません。しかし、将来的にユーザに roomId や serverUrl を変更してもらいたくなった場合(これらの値がリアクティブになるでしょう)でも、エフェクトのコードを変更する必要はありません。依存配列にこれらの値を追加するだけです。

コンポーネント本体で宣言された変数はすべてリアクティブである

リアクティブな値は、props や state だけではありません。これらの値から導出される値もまた、リアクティブな値となります。props や state が変更されるとコンポーネントは再レンダーされ、導出される値も変化します。これが、コンポーネント本体で宣言された変数で、エフェクトが利用するものは、すべてそのエフェクトの依存配列に含まなければならない理由です。

ユーザがドロップダウンでチャットサーバを選択できますが、設定でデフォルトサーバを指定することもできるとしましょう。設定の状態を表す state をすでにコンテクストに入れていると仮定し、そのコンテクストから settings を読み取ります。そして、props から得られる選択されたサーバと、デフォルトサーバに基づいて serverUrl を計算します。

function ChatRoom({ roomId, selectedServerUrl }) { // roomId はリアクティブ
const settings = useContext(SettingsContext); // settings はリアクティブ
const serverUrl = selectedServerUrl ?? settings.defaultServerUrl; // serverUrl はリアクティブ
useEffect(() => {
const connection = createConnection(serverUrl, roomId); // エフェクトは roomId と serverUrl を利用している
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId, serverUrl]); // そのため、どちらかが変更された場合は再同期が必要!
// ...
}

この例では、serverUrl は props でも state でもありません。レンダー時に計算される通常の変数です。しかし、レンダー時に計算されるがゆえに、再レンダーによって変化する可能性があります。これが、リアクティブな値となる理由です。

コンポーネント内のすべての値(props、state、コンポーネント本体の変数を含む)はリアクティブです。リアクティブな値は再レンダー時に変更される可能性があるため、エフェクトの依存配列に含める必要があります。

つまり、エフェクトはコンポーネント本体のすべての値に “反応 (react)” するのです。

さらに深く知る

グローバルな値やミュータブルな値は依存配列に含めるべき?

グローバル変数を含むミュータブル(書き換え可能)な値は、リアクティブではありません。

location.pathname のようなミュータブルな値を依存配列に含めることはできません。なにしろミュータブルなので、React のレンダーデータフローの外部で、いつでも書き換わってしまう可能性があります。外部で変更されても、コンポーネントの再レンダーはトリガされません。したがって、依存配列に含めたとしても、その値が変更されたときにエフェクトの再同期が必要だと React には伝わりません。また、レンダー中(依存関係を計算するとき)にミュータブルなデータを読み取ることは、レンダーの純粋性のルールを破っています。代替策として、外部のミュータブルな値の読み取りやリッスンをするには useSyncExternalStore を利用しましょう。

ref.current のようなミュータブルな値や、この値から導出される値も依存配列に含めることはできません。useRef によって返される ref オブジェクト自体は依存配列に含めることができますが、その current プロパティは意図的にミュータブルとなっています。これにより、再レンダーをトリガせずに値を追跡し続けることができます。しかし、current を変更しても再レンダーはトリガされないため、リアクティブな値とはいえず、その値が変更されたときにエフェクトを再実行することは React には伝わりません。

このページの後半で学びますが、リンタがこれらの問題を自動でチェックしてくれます。

React はすべてのリアクティブな値が依存配列に含まれることをチェックする

リンタが React 向けに設定されている場合、エフェクトで利用されているすべてのリアクティブな値が、その依存値として宣言されているかどうかをチェックします。例えば、以下のコードはリンタエラーとなります。なぜなら、roomId と serverUrl はどちらもリアクティブだからです。

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

function ChatRoom({ roomId }) { // roomId is reactive
  const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // serverUrl is reactive

  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); // <-- Something's wrong here!

  return (
    <>
      <label>
        Server URL:{' '}
        <input
          value={serverUrl}
          onChange={e => setServerUrl(e.target.value)}
        />
      </label>
      <h1>Welcome to the {roomId} room!</h1>
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}

このエラーは React のエラーのように見えますが、実際にはあなたのコードのバグを指摘してくれています。roomId と serverUrl は時間経過とともに変化する可能性がありますが、それらが変更されたときにエフェクトを再同期するのを忘れています。ユーザが UI で異なる値を選択しても、最初の roomId と serverUrl に接続し続けてしまいます。

バグを修正するには、リンタの提案に従って、エフェクトの依存配列に roomId と serverUrl を追加します。

function ChatRoom({ roomId }) { // roomId is reactive
const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // serverUrl is reactive
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [serverUrl, roomId]); // ✅ All dependencies declared
// ...
}

上記のサンドボックスでこの修正を試してみてください。リンタエラーがなくなり、チャットが適切に再接続されることを確認してください。

補足

コンポーネント内で宣言された変数でも、その値が決して変更されないことを React が知っているケースがあります。例えば、useState から返される set 関数 や、useRef から返される ref オブジェクトは、安定、すなわち、再レンダー時に変更されないことが保証されています。安定な値はリアクティブではないため、依存配列から省略することができます。なお、これらの値は変更されないため、配列に加えてしまっても問題はありません。

再同期を避けたい場合の方法

前の例では、roomId と serverUrl を依存配列に追加することで、リンタエラーを修正しました。

しかし、その代わりに、これらの値がリアクティブではない、つまり再レンダーされても変更し得ないことを、リンタに「証明」することもできます。例えば、serverUrl と roomId がレンダーに依存せず、常に同じ値を持つ場合、コンポーネントの外に移動することができます。これで、依存配列に含める必要はなくなりました。

const serverUrl = 'https://localhost:1234'; // serverUrl is not reactive
const roomId = 'general'; // roomId is not reactive

function ChatRoom() {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, []); // ✅ All dependencies declared
// ...
}

また、これらの値をエフェクトの内部に移動することもできます。レンダー時の計算から外れるため、リアクティブな値とはなりません。

function ChatRoom() {
useEffect(() => {
const serverUrl = 'https://localhost:1234'; // serverUrl is not reactive
const roomId = 'general'; // roomId is not reactive
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, []); // ✅ All dependencies declared
// ...
}

エフェクトはリアクティブなコードブロックです。エフェクトは、その中で読み取っている値が変更されたときに再同期します。イベントハンドラは 1 回のインタラクションにつき 1 回しか実行されませんが、エフェクトは同期が必要なときにいつでも実行されます。

依存配列を「選ぶ」ことはできません。依存配列には、エフェクトで読み取るすべてのリアクティブな値を含める必要があります。リンタがこれを強制します。これにより、無限ループや、エフェクトの再同期が頻発してしまうことがありますが、リンタを抑制してこれらの問題を解決としないでください! 代わりに、以下のことを試してみてください。

  • エフェクトが 1 つの独立した同期の処理を表していることを確認してください。もし、エフェクトが何も同期していない場合、エフェクトは不要かもしれません。複数の独立したものを同期している場合は、分割してください。

  • props や state に反応せずに、最新の値を読み取り、エフェクトを再同期したい場合、エフェクトをリアクティブな部分(エフェクト内に残す)と、非リアクティブな部分(いわゆるエフェクトイベントに抽出する)に分割することができます。詳しくは、エフェクトからイベントを分離するを参照してください。

  • オブジェクトや関数を依存配列に含めないようにしてください。レンダー中に作成したオブジェクトや関数をエフェクトから読み取ると、これらの値は毎回異なるものになります。これにより、エフェクトが毎回再同期されてしまいます。詳しくは、エフェクトから不要な依存関係を削除するを参照してください。

落とし穴

リンタはあなたの助けになりますが、その力には限界があります。リンタは、依存配列が間違っている場合しか検出できず、それぞれのケースを解決する最善の方法を提案することはできません。例えば、リンタの提案に従って依存値を追加すると、ループが発生してしまったとしましょう。このような場合でも、リンタを無視すべきではありません。エフェクトの内部(または外部)のコードを変更し、追加した値がリアクティブにならないようにして、依存値になる必要がないようにすべきです。

既存のコードベースがある場合、次のようにリンタを抑制するエフェクトがいくつかあるかもしれません。

useEffect(() => {
// ...
// 🔴 Avoid suppressing the linter like this:
// eslint-ignore-next-line react-hooks/exhaustive-deps
}, []);

以降のページ(こちらとこちら)では、ルールを破らずにこれらのコードを修正する方法を学びます。必ず修正する価値があります!

まとめ

  • コンポーネントはマウント、更新、アンマウントを行うことができる。
  • それぞれのエフェクトは、周囲のコンポーネントとは別のライフサイクルを持つ。
  • それぞれのエフェクトは、開始と停止が可能な独立した同期プロセスを表す。
  • エフェクトを読み書きするときは、コンポーネントの視点(どのようにマウント、更新、アンマウントが行われるか)ではなく、それぞれのエフェクトの視点(どのように同期が開始および停止されるか)で考える。
  • コンポーネント本体で宣言された値は “リアクティブ” である。
  • リアクティブな値は時間とともに変化する可能性があるため、エフェクトを再同期する必要がある。
  • リンタは、エフェクト内で使用されているすべてのリアクティブな値が依存配列に含まれることを確認する。
  • リンタが検出するエラーは、すべて妥当なものである。ルールを破らずにコードを修正する方法が必ず存在する。

チャレンジ 1/5:
キー入力による再接続を防ぐ

この例では、ChatRoom コンポーネントは、マウントされたときにチャットルームに接続し、アンマウントされたときにルームから切断し、別のチャットルームが選択されたときに再接続します。これは正しいので、この動作を維持する必要があります。

しかし、問題があります。下部のメッセージボックスに入力するたびに、ChatRoom はチャットに再接続してしまいます(コンソールを 1 度クリアしてから入力すると分かりやすいです)。問題を修正し、これを防ぎましょう。

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

const serverUrl = 'https://localhost:1234';

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

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

  return (
    <>
      <h1>Welcome to the {roomId} room!</h1>
      <input
        value={message}
        onChange={e => setMessage(e.target.value)}
      />
    </>
  );
}

export default function App() {
  const [roomId, setRoomId] = useState('general');
  return (
    <>
      <label>
        Choose the chat room:{' '}
        <select
          value={roomId}
          onChange={e => setRoomId(e.target.value)}
        >
          <option value="general">general</option>
          <option value="travel">travel</option>
          <option value="music">music</option>
        </select>
      </label>
      <hr />
      <ChatRoom roomId={roomId} />
    </>
  );
}