LoginSignup
2
1

useEffectの依存配列に対応する心構え

Posted at

useEffectおさらい

useEffectはReactが管理しない外の世界、つまりブラウザAPIやネットワークなどの通信を扱ための適切な対処法がない時に利用される避難ハッチとして準備されている機能です。
コンポーネントが描画されてから一定の秒毎にコンソールへ1を出力する場合はuseEffectを使って以下のように記述されます。

useEffect(() => {
  const id = setInterval(() => {
    console.log(1);
  }, delay);

  return () => {
    clearInterval(id);
  };
}, [delay]);

useEffectに渡したコールバック関数は、これが記述されたコンポーネントを画面へ初期描画した後に呼び出されます。返り値の関数(以後、クリーアップ関数と呼びます)はすぐには呼び出されずReactに記録されます。
その後、何らかの影響によってコンポーネントの再レンダリングが起きた際には、useEffectの第2引数の配列(以後、依存配列と呼びます)の値がレンダリング前後で変化がないかObject.isで比較します。変化があった場合はそのレンダリングの結果を画面へ描画した後に前回のレンダリングで記録したクリーンアップ関数を呼び出して、さらにその後に直近のレンダリング時の値でコールバック関数を実行します。
コンポーネントが利用されなくなったとき(アンマウント時)は最後の実行時のクリーンアップ関数を呼び出します。

先ほどの例では、初期描画後にsetIntervaldelay秒毎にconsole.log(1)を呼び出すような関数が実行され、そこで設定したsetIntervalidをパージする関数をクリーンアップ関数として記録します。そして、幾度かの最レンダリングでdelayが変更されたとき、そのクリーンアップ関数を呼び出して、再度直近のレンダリング時のdelaysetIntervalを登録します。そして、コンポーネントが利用されなくなったときは最後のクリーンアップ関数を呼び出して、setIntervalとの同期を解除します。

依存配列の種類

先ほどの例では依存配列に中身がありましたが、空配列の場合と何も渡されない場合があります。
中身がある場合は先ほど紹介したようにレンダリングのたびに値を比較して変化があればクリーンアップ関数とコールバック関数の呼び出しを描画後に行います。
空配列の場合は比較する値がないので、初期描画時にコールバック関数を呼び出して、利用されなくなった時にクリーンアップ関数を呼び出します。
何も渡さない場合はレンダリングして描画した後にクリーンアップ関数とコールバック関数が毎回呼び出されます。

// レンダリング毎に呼び出される
useEffect(() => {
  const delay = 1000;
  const id = setInterval(() => {
    console.log(1);
  }, delay);

  return () => {
    clearInterval(id);
  };
});
// 初期レンダリングでのみ呼び出される
useEffect(() => {
  const delay = 1000;
  const id = setInterval(() => {
    console.log(1);
  }, delay);

  return () => {
    clearInterval(id);
  };
}, []);
// delayが変更された時のみ呼び出される
useEffect(() => {
  const id = setInterval(() => {
    console.log(1);
  }, delay);

  return () => {
    clearInterval(id);
  };
}, [delay]);

依存配列に渡す値

依存配列に渡す値はリアクティブな値だけにしてください。
リアクティブな値とは、コンポーネント内に宣言されたpropsや状態、その他の値を指します。

import { importedValue } from '@';

const globalValue = 1;

const Sample: FC<{ prop: number }> = ({
  prop,
}) => {
  const ref = useRef(0);
  const [state, setState] = useState(0);
  const calulatedValue = calc(state);

  return <Component />;
};

上記の例でリアクティブな値はpropstatecalulatedValueです。レンダリング時に計算され、Reactのデータフローに含まれる値がリアクティブな値というわけです。

globalValueはグローバルな定数でレンダリング毎に変わりませんし、importedValueも変わりません。厳密には変数の定義の方法を変えて値を変えることは可能ですが、それはレンダリングの純粋性を破ってしまうのでReactのルールとして変わってはいけないです。

refは最初の定義に照らし合わせるとリアクティブな値ですが、refは常に同一のオブジェクトが返されるので依存配列に渡す意味はありませんし、ref.currentはミュータブルな値ですがレンダリングに関連する値ではないので依存配列には渡しません。
同様にuseStateから返されるsetStateも常に同一のオブジェクトが返されるので、依存配列に含める必要はありません。

リアクティブではない値を渡すことでuseEffectの挙動が変わることはありませんが、依存配列の複雑さが増すので思わぬ不具合につながることがあります。リアクティブな値だけを渡すようにしてください。
もし、リアクティブでは無い値を渡すことで動作が成り立っているとしたらコンポーネントの純粋性が失われているので、コンポーネント設計自体を見直すようにしましょう。

依存配列に渡すリアクティブな値ですが、ライフサイクルを想定して選ぶのではなく、コールバック関数内で利用したリアクティブな値を機械的に渡すようにしてください。コールバック関数内で利用されていない値が含まれると無駄な再同期が起きてしまいますし、コールバック関数内で利用した値が含まれていないと適切なタイミングで再同期が行われず不具合につながります。
useEffectを利用する際は外の世界との同期の開始と停止方法をコールバック関数で記述して、関数に含まれるリアクティブな値を依存配列に列挙するようなイメージです。

オブジェクトや関数が依存配列に含まれる場合

依存配列の値の変化はObject.isで行われるのでオブジェクトや関数を利用する際は注意が必要です。
以下のようなコンポーネントを考えます。

const Sample: FC = () => {
  const [count, setCount] = useState(0);
  const [name, setName] = useState<{ familyName: string; givenName: string }>({
    familyName: 'Albert',
    givenName: 'Einstein',
  });

  useEffect(() => {
    const id = setTimeout(() => {
      console.log(`${name.familyName} ${name.givenName}`);
    }, 1000);;

    return () => {
      clearTimeout(id);
    };
  }, [name]);

  return <DummyComponent />;
};

このコンポーネントはnameが変更されて1秒後にコンソールへ名前が出力されるようにと思って作成しました。
しかし、コンソールへの出力はレンダリングのたびに、例えばcountの変更によっても行われます。
理由はnameがオブジェクトだからです。nameuseStateによってレンダリング毎に生成されるので、依存配列のたびに異なるオブジェクトとして評価されているのです。これは関数でも同様です。

これを解決する他の方法が2つあります。
1つは依存配列に渡す値をオブジェクトから取り出した値にすることです。

const Sample: FC = () => {
  const [name, setName] = useState<{ familyName: string; givenName: string }>({
    familyName: 'Albert',
    givenName: 'Einstein',
  });

  useEffect(() => {
    const id = setTimeout(() => {
      console.log(`${name.familyName} ${name.givenName}`);
    }, 1000);;

    return () => {
      clearTimeout(id);
    };
  }, [name.familyName, name.givenName]);

  return <DummyComponent />;
};

依存配列を[name.familyName, name.givenName]とすることで、文字列同士の比較が可能になります。これで、レンダリング毎に異なる依存配列として評価されることなく、適切なタイミングでクリーンアップ・コールバック関数の発火を行えます。
2つ目はuseCallbackuseMemoを活用して、同一のオブジェクトを活用することです。

const Sample: FC = () => {
  const [familyName, setFamilyName] = useState('Albert');
  const [givenName, setGivenName] = useState('Einstein');

  const name = useMemo(() => {
    return {
      familyName, 
      givenName,
    };
  }, [familyName, givenName]);

  useEffect(() => {
    const id = setTimeout(() => {
      console.log(`${name.familyName} ${name.givenName}`);
    }, 1000);;

    return () => {
      clearTimeout(id);
    };
  }, [name]);

  return <DummyComponent />;
};

説明がしやすいように2つの状態を組み合わせて値を取り出すようにしました。
上記のように、useMemouseCallbackを使うことでレンダリングではなく、それぞれの依存配列が変化するたびにオブジェクトや関数を作り直すようになりました。同一の値ですのでObject.isの比較に引っ掛かることもなく適切なタイミングでクリーンアップ・コールバック関数の発火を行えます。
この方法をpropsのオブジェクトや関数に適用すると、親コンポーネントの実装を制限・信頼する必要があるのでeslintなどで何らかのルールで縛れない限りはやめましょう。

直接的な解決方法は上記の通りですが、そもそもリアクティブなオブジェクトや関数を作らない方向でも解決できます。

// オブジェクトではなく文字列を組み立てる
const [familyName, setFamilyName] = useState('Albert');
const [givenName, setGivenName] = useState('Einstein');

const fullName = `${familyName} ${givenName}`

useEffect(() => {
  const id = setTimeout(() => {
    console.log(fullName);
  }, 1000);

  return () => {
    clearTimeout(id);
  };
}, [fullName]);

// コールバック関数内でオブジェクトを組み立てる
const [familyName, setFamilyName] = useState('Albert');
const [givenName, setGivenName] = useState('Einstein');

useEffect(() => {
  const name = {
    familyName,
    givenName,
  };
  const id = setTimeout(() => {
    console.log(`${name.familyName} ${name.givenName}`);
  }, 1000);

  return () => {
    clearTimeout(id);
  };
}, [familyName, givenName]);

それでも依存配列から値を取り除きたい

実験的な機能についての紹介です

依存配列は選ぶものでなく、コールバック関数内で使われたリアクティブな値に基づいて自動で決めると記述しました。しかし、どうしても利用するリアクティブな値の変更に応じた再同期を行いたくないことはあります。
例えば、初期描画の1秒後にモーダルを出すようなコンポーネントがあるとして、モーダルの背景はリアクティブな値によって決まるような例を考えます。

const { onOpen, onClose } = useModal();
const [color, setColor] = useState('#CCCCCC');

useEffect(() => {
  const id = setTimeout(() => {
    onOpen({ bgcolor: color });
  }, 1000);

  return () => {
    clearTimeout(id);
  };
}, [color]);

このコンポーネントは一見正しそうに見えますが、colorが変更されるたびにモーダルが開かれる点は想定外です。
colorはコールバック関数内で呼び出されるリアクティブな値なのにリアクティブな値をとして振る舞って欲しくないです。
これはコンポーネントとイベントハンドラの関係に似ています。コンポーネント内には純粋なコードを記述したいですが、イベントハンドラには非純粋的なコードを記したいです。そして、それはレンダリングのようなライフサイクルに合わせて利用されるコードではなく、ユーザーの操作のような任意のタイミングで値を読み取って実行したい時に現れます。
このような関係をuseEffectで生み出すために、値、ロジックをエフェクト関数から脱出させるuseEffectEventと呼ばれるhooksが実験的に提供されています。

const { onOpen, onClose } = useModal();
const [color, setColor] = useState('#CCCCCC');

const onModalOpen = useEffectEvent(() => {
  onOpen({ bgcolor: color });
});

useEffect(() => {
  const id = setTimeout(() => {
    onModalOpen();
  }, 1000);

  return () => {
    clearTimeout(id);
  };
}, []);

onModalOpenuseEffectから切り出された別世界のリアクティブではない値として扱えるので、依存配列からcolorを取り除いた上でonModalOpensetTimeoutのタイミングで呼び出せます。
先ほどのコンポーネントとイベントハンドラの議論に対応して考えると、今回onModalOpenを分離できたのはそれが同期の開始・停止のようなライフサイクルに合わせて呼び出されるものではなく、同期した先の任意のタイミングで実行されるような関数だったからです。
いつでも分離して良いわけではないのでそれが妥当ことの理由を考えて利用するようにしてください。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1