👏

React Docs Effectの依存関係を取り除く

2023/06/29に公開

Removing Effect Dependencies

Effectを書くとき、リンターはEffectが読み取るすべてのリアクティブな値(propsstateなど)が依存関係リストに含まれていることを確認する。これにより、Effectがコンポーネントの最新のpropsstateに同期していることが保証される。不要な依存関係があるとEffectが頻繁に実行されたり、無限ループになったりすることがあるため、以下のガイドに従ってEffectsの不要な依存関係を取り除く。

You will learn

  • Effectの無限依存ループを修正する方法
  • 依存関係を削除したいときの対処法
  • Effectから"反応"させずに価値を読み取る方法
  • オブジェクトと関数の依存関係を回避する方法と理由
  • 依存性リンターを抑制することが危険な理由と、その代わりに何をすべきなのか

依存関係はコードと一致させる

Effectを書くときはまず、動作を開始・停止する方法を指定する。

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

function ChatRoom({ roomId }) {
  useEffect(() => {
    // 開始
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    // 停止
    return () => connection.disconnect();
  	// ...
}

そして、Effect dependenciesを空([])にしておくと、リンターが正しい依存関係を提案してくれるため、そのように修正する。

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

Effectsはリアクティブな値に"反応"し、ここではroomIdは再レンダリングによって変化する可能性があるリアクティブな値なので、リンターは依存関係としてそれを指定していることを確認する。

依存関係を取り除くにはそれが依存関係でないことを証明すること

Effectの依存関係を選択することはできないことに注意。Effectのコードで使用されるすべてのリアクティブな値は依存関係リストで宣言する必要があり、依存関係リストは周辺のコードによって決定される。

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

function ChatRoom({ roomId }) { // This is a reactive value
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId); // This Effect reads that reactive value
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // ✅ So you must specify that reactive value as a dependency of your Effect
  // ...
}

リアクティブな値にはpropsやコンポーネント内部で直接宣言されたすべての変数と関数が含まれる。roomIdはリアクティブな値であるため、依存関係から削除することはリンターがそれを許さないためできないし、リンターは正しいだろう。

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

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); // 🔴 React Hook useEffect has a missing dependency: 'roomId'
  // ...
}

依存関係を取り除くには依存関係である必要がないことをリンターに証明する必要がある。
例えば上記例ではroomIdを外に出すことでそれがリアクティブでなく、再レンダリングで変化しないことを証明することができる。こうすることで、Effectはどのリアクティブな値にも依存せず、コンポーネントのどのpropstateが変化しても再実行する必要がなくなるため、空の依存関係リスト([])を指定することができる。

const serverUrl = 'https://localhost:1234';
const roomId = 'music'; // Not a reactive value anymore

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

依存関係を変更するにはコードを変更する

依存関係を変更するにはコードを変更する。
ワークフローにパターンがあることには気づいているだろうか。

  1. まず、Effectのコードやリアクティブ値の宣言方法を変更する
  2. 次にリンターにしたがって変更したコードに一致するように依存関係を調整する
  3. 依存関係のリストに不満があれば最初のステップに戻って再度コードを変更する
    依存関係リストはEffectのコードが使用するすべてのリアクティブな値のリストと考えることができ、そのリストを変更したい場合はEffectのコードを修正する必要がある。依存関係リストに何を記述するかはこちらが決めることではない。

これは方程式を解くような感覚かもしれず、ある目標(例えば依存関係を取り除くなど)からスタートしてその目標に合致するコードを見つける必要がある。

不要な依存関係を取り除く

コードを修正し、Effectの依存関係を調整する度に、それらの依存関係のいずれかが変更されたときにEffectの再実行が必要か確認し、時には必要ない場合もあるだろう。

  • Effectの異なる部分を異なる条件で再実行したい場合
  • 依存関係の変更に反応するのではなく依存関係の最新の値だけを読みたいという場合
  • 依存関係がオブジェクトや関数であるため、意図せず頻繁に変更される可能性がある場合

正しい解決策を見つけるにはEffectに関するいくつかの質問に答える必要がある。

このイベントはイベントハンドラに移すべきか?

まず考えるのはコードがEffectであるべきかどうかである。
送信時にsubmitステートをtrueに変更、POSTリクエスト送信、通知を表示する必要があるフォームがあるとする。
これらのロジックをsubmittrueであることに反応するEffectの中に入れている。

function Form() {
  const [submitted, setSubmitted] = useState(false);

  useEffect(() => {
    if (submitted) {
      // 🔴 Avoid: Event-specific logic inside an Effect
      post('/api/register');
      showNotification('Successfully registered!');
    }
  }, [submitted]);

  function handleSubmit() {
    setSubmitted(true);
  }

  // ...
}

その後、現在のテーマに従って通知メッセージのスタイルを変更することになったため、現在のテーマを読み込むようにする。テーマはコンポーネントで宣言されているリアクティブな値であるため依存関係として追加する。

function Form() {
  const [submitted, setSubmitted] = useState(false);
  const theme = useContext(ThemeContext);

  useEffect(() => {
    if (submitted) {
      // 🔴 Avoid: Event-specific logic inside an Effect
      post('/api/register');
      showNotification('Successfully registered!', theme);
    }
  }, [submitted, theme]); // ✅ All dependencies declared

  function handleSubmit() {
    setSubmitted(true);
  }  

  // ...
}

最初にフォームを送信し、その後テーマを切り替えるとその変更によりEffectが再実行されるため同じ通知を表示することになる。
ここでの問題はそもそもこれらの処理がEffectであってはならないということである。POSTリクエストの送信とフォームの送信に応じて通知を表示することは特定のインタラクションであり、その場合はそのロジックを対応するイベントハンドラに直接記述する。

function Form() {
  const theme = useContext(ThemeContext);

  function handleSubmit() {
    // ✅ Good: Event-specific logic is called from event handlers
    post('/api/register');
    showNotification('Successfully registered!', theme);
  }  

  // ...
}

こうすることで、ユーザーがフォームを送信したときだけ実行されるようになる。イベントハンドラとEffectの使い分け不要なEffectを削除する方法についてはそれぞれのリンク先で説明。

Effectが無関係なことをいくつもやっていないか?

次の質問はEffectが無関係なことをいくつもやっていないかということである。

ユーザーが都市と地域を選択する必要がある配送フォームを作成するとする。ここでは選択された国に応じてサーバーから都市のリストを取得し、ドロップダウンで表示する。

function ShippingForm({ country }) {
  const [cities, setCities] = useState(null);
  const [city, setCity] = useState(null);

  useEffect(() => {
    let ignore = false;
    fetch(`/api/cities?country=${country}`)
      .then(response => response.json())
      .then(json => {
        if (!ignore) {
          setCities(json);
        }
      });
    return () => {
      ignore = true;
    };
  }, [country]); // ✅ All dependencies declared

  // ...

これはcountry propに従って都市のステートをネットワークと同期させており、Effectでデータを取得するいい例である。ShippingFormが表示され、国が変わるたびにフェッチする必要があるため、イベントハンドラで同じことはできない。

ここで現在選択されている都市の地域をフェッチするために、都市の地域用セレクトボックスを追加するとする。まず、同じEffectの中にある地域リストに対してのフェッチ処理を追加する。

function ShippingForm({ country }) {
  const [cities, setCities] = useState(null);
  const [city, setCity] = useState(null);
  const [areas, setAreas] = useState(null);

  useEffect(() => {
    let ignore = false;
    fetch(`/api/cities?country=${country}`)
      .then(response => response.json())
      .then(json => {
        if (!ignore) {
          setCities(json);
        }
      });
    // 🔴 Avoid: A single Effect synchronizes two independent processes
    if (city) {
      fetch(`/api/areas?city=${city}`)
        .then(response => response.json())
        .then(json => {
          if (!ignore) {
            setAreas(json);
          }
        });
    }
    return () => {
      ignore = true;
    };
  }, [country, city]); // ✅ All dependencies declared

  // ...

こうすると、Effectがcity stateを使用することになるため、依存関係にcityを追加する必要があるが、これだとユーザーが別の都市を選択するとEffectが再実行され、不要に何度も都市リストを再フェッチすることになる。
このコードの問題点は以下2つの異なる無関係なものを同期させていることである。

  1. country propに基づいてcitystateをネットワークに同期させたい
  2. city propに基づいてareasstateをネットワークに同期させたい

これらを2つのEffectsに分割し、それぞれ関係するpropによって再実行されるようにする。

function ShippingForm({ country }) {
  const [cities, setCities] = useState(null);
  // countryに基づいてcityを同期させる
  useEffect(() => {
    let ignore = false;
    fetch(`/api/cities?country=${country}`)
      .then(response => response.json())
      .then(json => {
        if (!ignore) {
          setCities(json);
        }
      });
    return () => {
      ignore = true;
    };
  }, [country]); // ✅ All dependencies declared

  const [city, setCity] = useState(null);
  const [areas, setAreas] = useState(null);
  // cityに基づいてareasを同期させる
  useEffect(() => {
    if (city) {
      let ignore = false;
      fetch(`/api/areas?city=${city}`)
        .then(response => response.json())
        .then(json => {
          if (!ignore) {
            setAreas(json);
          }
        });
      return () => {
        ignore = true;
      };
    }
  }, [city]); // ✅ All dependencies declared

  // ...

こうすることで、1つ目のEffectは国が変わったときだけ再実行され、2つ目のEffectは都市が変わったときだけ再実行されるようになる。2つの異なるものを2つの別々のEffectsで同期させるという目的別にわけている。
最終的なコードは修正前に比べると長くなってしまうが各Effectは独立した同期処理を表す必要があるため、これらのEffectsを分割することは正しい。コードの重複が気になる場合は繰り返しのロジックをカスタムHookに抽出することで改善できる。

次のstateを計算するためにあるstateを読み取っているか?

以下Effectは新しいメッセージが届くたびに新しく作成した配列でmessages stateを更新している。
messages stateを使って既存のすべてのメッセージで始まる新しい配列を作成し、最後に新しいメッセージを追加しているが、messagesはEffectが読み取るリアクティブな値なので依存関係に含める必要がある。

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      setMessages([...messages, receivedMessage]);
    });
    return () => connection.disconnect();
  }, [roomId, messages]); // ✅ All dependencies declared
  // ...

messagesを依存関係に入れることはバグを混入している。
メッセージを受信するたびにsetMessagesによって受信したメッセージを含む新しいメッセージでコンポーネントが再レンダリングされるが、Effectはメッセージに依存するようになったためEffectの再同期も行われるため、チャットの再接続も行われるようになる。
この問題を解決するにはEffect内部でメッセージを読み込むのではなくsetMessagesupdater関数を渡すようにする。

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      setMessages(msgs => [...msgs, receivedMessage]);
    });
    return () => connection.disconnect();
  }, [roomId]); // ✅ All dependencies declared
  // ...

こうすることで、Effectがmessages stateを読み込む必要がなくなり、msgs => [...msgs, receivedMessage]のようにupdater関数を渡すだけで良くなる。Reactはupdater関数をキューに入れ、次のレンダリング時にmsgsの引数を渡すため、Effect自身がメッセージに依存する必要がなくなった。

値の変化に反応せずに読み解くか?

React安定板でまだリリースしていないAPIを含む
例えばユーザーが新しいメッセージを受信したときに、isMutedfalseであれば音声を再生したいとする。
Effect内でisMutedを使うため依存関係に追加する必要がある。

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  const [isMuted, setIsMuted] = useState(false);

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      setMessages(msgs => [...msgs, receivedMessage]);
      if (!isMuted) {
        playSound();
      }
    });
    return () => connection.disconnect();
  }, [roomId, isMuted]); // ✅ All dependencies declared
  // ...

ここでの問題はisMutedが変わるたび(ユーザーがミュートトグルを操作したとき)、Effectが再同期し、チャットに再接続されることである。この問題を解決するには、反応させるべきでないロジックをEffectから抽出する必要がある。このEffectはisMutedの変化に対して反応させたくないためそのロジックをEffect Eventに移動させる

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

function ChatRoom({ roomId }) {
  const [messages, setMessages] = useState([]);
  const [isMuted, setIsMuted] = useState(false);

  const onMessage = useEffectEvent(receivedMessage => {
    setMessages(msgs => [...msgs, receivedMessage]);
    if (!isMuted) {
      playSound();
    }
  });

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      onMessage(receivedMessage);
    });
    return () => connection.disconnect();
  }, [roomId]); // ✅ All dependencies declared
  // ...

Effect Eventを使うと、Effectをリアクティブな部分(roomIdやその変化に反応する部分)とリアクティブでない部分(onMessageisMutedを見るように最新の値だけを読む部分)に分割できる。Effect Eventの中でisMutedを読むようになったため、Effectの依存関係である必要はなくなる。

propsからイベントハンドラをラッピングする

コンポーネントがイベントハンドラをpropsとして受け取る場合にも同様の問題に遭遇することがある。
親コンポーネントがレンダリングごとに異なるonReceiveMessageを渡すとする。

<ChatRoom
  roomId={roomId}
  onReceiveMessage={receivedMessage => {
    // ...
  }}
/>

function ChatRoom({ roomId, onReceiveMessage }) {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      onReceiveMessage(receivedMessage);
    });
    return () => connection.disconnect();
  }, [roomId, onReceiveMessage]); // ✅ All dependencies declared
  // ...

onReceiveMessageは依存関係に含まれるため、親の再レンダリングの度にEffectが再実行され、チャットに再接続することになる。これを解決するにはこの呼び出しをEffect Eventでラップする。

function ChatRoom({ roomId, onReceiveMessage }) {
  const [messages, setMessages] = useState([]);

  const onMessage = useEffectEvent(receivedMessage => {
    onReceiveMessage(receivedMessage);
  });

  useEffect(() => {
    const connection = createConnection();
    connection.connect();
    connection.on('message', (receivedMessage) => {
      onMessage(receivedMessage);
    });
    return () => connection.disconnect();
  }, [roomId]); // ✅ All dependencies declared
  // ...

Effect Eventはリアクティブではないため、依存関係として指定する必要はなく、親コンポーネントが再レンダリングごとに異なる関数を渡してもチャットが再接続されることはなくなる。

リアクティブコードとそうでないコードの切り分け

以下の例ではroomIdが変更されるたびにページへの訪問を記録したいとする。すべてのログにnotificationCountを含めたいが、notificationCountの変更がログイベントをトリガーすることは望んでいない。この解決策もEffect Eventに分割することである。

function Chat({ roomId, notificationCount }) {
  const onVisit = useEffectEvent(visitedRoomId => {
    logVisit(visitedRoomId, notificationCount);
  });

  useEffect(() => {
    onVisit(roomId);
  }, [roomId]); // ✅ All dependencies declared
  // ...
}

roomIdに関してロジックをリアクティブにしたいため、 Effectの内部でroomIdonVisitの引数として渡すが、notificationCountの変更ではログを送信したくないためEffect Eventの内部でnotificationCountを読み取る。Effect Eventを使ってEffectから最新のpropsstateを読み取る方法については別ページで説明

リアクティブな値が意図せずに変化することがある?

ある値に対してEffectを反応させたいが、その値が思ったよりも頻繁に変化し、ユーザー視点から実際の変化を反映しない場合がある。例えば、コンポーネント内でオプションオブジェクトを作成し、Effectの内部でそのオブジェクトを読み込むとする。

function ChatRoom({ roomId }) {
  // ...
  const options = {
    serverUrl: serverUrl,
    roomId: roomId
  };
  useEffect(() => {
    const connection = createConnection(options);
    connection.connect();
    // ...

このオブジェクトはコンポーネント内で宣言されているためリアクティブな値であり、Effect内でこの値を読み込む場合、それを依存関係とする必要がある。こうすることでEffectがその変更に反応することが保証される。

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

これにより、roomIdが変更された場合、Effectが新しいオプションでチャットに再接続されることが保証されるが、このコードには問題がある。


入力フォームはメッセージのステートを更新するだけで、ユーザー視点から見るとチャット接続に影響を与えないはずだが、メッセージを更新するたびいにコンポーネントが再レンダリングされ、その中のコードが一から実行される。

ChatRoomコンポーネントを再レンダリングするたびに新しいオプションオブジェクトがゼロから作成される。Reactはオブジェクトが前回レンダリングされたものと異なるオブジェクトであると認識し、Effectがが再実行される。
この問題は、オブジェクトと関数にのみ影響する。JavaScriptでは新しく作られたオブジェクトや関数は中身が同じであってもすべての他のものとは異なるものとみなされる。

// During the first render
const options1 = { serverUrl: 'https://localhost:1234', roomId: 'music' };

// During the next render
const options2 = { serverUrl: 'https://localhost:1234', roomId: 'music' };

// These are two different objects!
console.log(Object.is(options1, options2)); // false

オブジェクトや関数の依存関係によってEffectの再実行が必要以上に頻繁に行われることがある。
そのため、可能な限りオブジェクトや関数をEffectの依存先として使用しないようにする必要がある。その代わりにコンポーネントの外側やEffectの内側に移動させたり、プリミティブな値を取り出したりすることを試してみる。

静的なオブジェクトや関数をコンポーネントの外側に移動させる

オブジェクトがpropsstateに依存していない場合、そのオブジェクトをコンポーネントの外に移動させることができる。

function createOptions() {
  return {
    serverUrl: 'https://localhost:1234',
    roomId: 'music'
  };
}

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

  useEffect(() => {
    const options = createOptions();
    const connection = createConnection();
    connection.connect();
    return () => connection.disconnect();
  }, []); // ✅ All dependencies declared
  // ...

createOptionsはコンポーネントの外で宣言されるため、リアクティブな値ではないため、Effectの依存関係で指定する必要はなく、Effectの再実行を引き起こすこともない。

Effecttの中でダイナミックなオブジェクトや関数を動かす

オブジェクトがroomId propのように、再レンダリングによって変化する可能性のあるリアクティブな値に依存している場合、それをコンポーネントの外側に引き出すことはできないが、Effectのコード内でオブジェクトを作成することは可能。

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]); // ✅ All dependencies declared
  // ...

Effectの内部でoptionsが宣言されたことでEffectの依存関係ではなくなり、Effectが使用する唯一の反応値はroomIdのみとなる。roomIdはオブジェクトや関数ではないので、意図せず異なる値になることはないだろう。(JavaScriptでは、数値と文字列はその内容で比較される)

// During the first render
const roomId1 = 'music';

// During the next render
const roomId2 = 'music';

// These two strings are the same!
console.log(Object.is(roomId1, roomId2)); // true

この修正のおかげで入力を編集してもチャットが再接続されることがなくなる。

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

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

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

  useEffect(() => {
    // Effect内でオブジェクトを宣言
    const options = {
      serverUrl: serverUrl,
      roomId: roomId
    };
    // 関数でも同じことができる
    // function createOptions() {
    //   return {
    //     serverUrl: serverUrl,
    //     roomId: roomId
    //   };
    // }

    const options = createOptions();
    const connection = createConnection(options);
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]);

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

Effectの中でロジックの一部をグループ化するために、独自の関数を記述することができる。Effectの内部で宣言する限りそれらのリアクティブな値ではないためEffectの依存関係になる必要はない。

オブジェクトからプリミティブな値を読み取る

propsからオブジェクトを受け取ることもある。

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

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

このリスクはレンダリング時に親コンポーネントがオブジェクトを作成していることである。

<ChatRoom
  roomId={roomId}
  options={{
    serverUrl: serverUrl,
    roomId: roomId
  }}
/>

この場合、親コンポーネントが再レンダリングするたびにEffectが再実行されることになる。これを解決するにはEffectの外側にあるオブジェクトから情報を読み取り、オブジェクトと関数の依存関係を持たないようにする。

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

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

ロジックは少し繰り返しになる(Effectの外側で同じ値を持つオブジェクトからいくつかの値を読み取り、Effectの内側で同じ値を持つオブジェクトを作成する)が、Efectが実際に依存する情報を非常に明確にすることができる。親コンポーネントによって意図せずオブジェクトが再作成された場合、チャットは再接続されないが、options.roomIdoptions.serverUrlが本当に異なる場合は再接続される。

関数からプリミティブ値を算出する

同じ方法は関数でも有効であり、例えば親コンポーネントが関数を渡すとする。

<ChatRoom
  roomId={roomId}
  getOptions={() => {
    return {
      serverUrl: serverUrl,
      roomId: roomId
    };
  }}
/>

依存関係になるのを避けるため(再レンダリング時に再実行が発生するため)、Effectの外で呼び出す。これにより、オブジェクトではないroomIdserverUrlの値が得られ、Effectの内部から読み取ることができるようになる。

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

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

これはレンダリング中に呼び出しても安全なため、純粋な関数にのみ機能する。関数がイベントハンドラであり、その変更によってEffectを再実行させたくない場合は、Effect Eventにラップすればよい。

Discussion