82
59

More than 3 years have passed since last update.

React の useEffect で無限ループが発生するメカニズムを理解する

Last updated at Posted at 2021-07-04

背景

  • 普段レビューやペアプロの時に useEffect の第二引数に何を入れたらいいのか、何を入れてはいけないのか説明するのが大変なので、予めまとめておくことにした。

前提知識

仮想DOM

  • React はブラウザがページを再読み込みしなくてもページ内の要素の表示を切り替えるために、ブラウザ内の DOM を動的に書き換えている。
  • この時 React は「ページ内の DOM ツリーがどういう状態であるべきか」を表すオブジェクト(仮想DOM)を内部に持っている。
  • React はアプリケーションの state が書き換わる度に仮想DOMを更新し、前回の仮想DOMと比較して差分がある時のみ実際のDOMを書き換えることで、DOM書き換え時に発生するブラウザの負担をできるだけ抑えている。
import React from 'react';

const Counter: React.FC = () => {
  const [count, setCount] = useState(0);

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(i => i + 1)}>count up!</button>
    </div>
  );
};

export default Counter;
  • その知識を前提に改めて上記のような functional component を眺めてみると「Counter 関数を実行すると、内部の状態に応じてDOMがどうあるべきかを計算して返す」という内容になっていることが分かる。
  • React はこのようなコンポーネントを表す関数を最上部にある要素から末端のコンポーネントに至るまで順番に実行し、その返り値を受け取って「全体として DOM がどうあるべきか」を表す巨大なオブジェクトを生成する。
  • アプリケーションの状態が切り替わる度に、その状態を持つコンポーネント以下の全ての子コンポーネント関数を実行し、その結果として生じた差分を実DOMに反映していく。
  • このように「ユーザー操作→状態の更新→仮想DOMの更新→差分を実DOMに反映」というサイクルを繰り返すことで、 React は動的な表示の切り替えを行っている。

コンポーネントの副作用

  • 表示の切り替えはそれでいいとして、我々は普段コンポーネントを作っている時に、副作用を起こしたいと思うことがある。
  • 例えば「このコンポーネントが mount された時に1回だけ API を叩いてデータを取得したい」といった処理を考える。
  • Functional Component は、そのコンポーネントもしくはその上位のコンポーネントの state が更新される度に実行されるため、例えば下記のように無邪気に副作用をコンポーネント内に書いてしまうと、 state が更新される度に何度も何度も API を叩いてしまう。
const Counter: React.FC = () => {
  const [count, setCount] = useState(0);

  // 無邪気に API を叩く
  fetch('/todos').then(res => res.json()).then(data => setCount(data.length));

  return (
    <div>
      <p>{count}</p>
      <button onClick={() => setCount(i => i + 1)}>count up!</button>
    </div>
  );
};
  • そこで「mount された時に1回だけ実行する」を実現するために使えるのが useEffect である。

useEffect

useEffect(effect, deps);
  • useEffect は第一引数に関数 effect を取り、第二引数に配列 deps を取る。
  • effect は、そのコンポーネントが返す仮想 DOM の差分が実 DOM に反映された直後に実行される。
  • depseffect が依存する値を書き込む(依存する値とは何なのか、については後で説明する)。
  • React は deps に渡された値が前回のレンダリング時と比べて更新されていた場合のみ effect を実行する。
useEffect(() => {
  console.log(count);
}, [count]);
  • 上記の例で言うと、このコンポーネントがレンダリングされる度に React は count の値が変更されたかどうかを監視して、前回レンダリング時と値が変わっていた場合のみ () => { console.log(count); } を実行する。 count の値が変わっていなかった場合は、何度レンダリングされてもこの関数は実行されない。
  • deps に空の配列を渡した場合、初回レンダリング時のみ effect が実行され、その後はコンポーネントが unmount されて再度 mount されるまで実行されない。

無限ループするパターン

  • useEffect を使っていると、意図せず effect の実行が無限に繰り返されてしまうことがある。
  • effect は第二引数に入れた deps が前回レンダリング時と比較して「異なる」場合に実行される。
  • つまり、意図せず「前回と異なる値である」と React に判断されてしまっていることが原因となる。それはどんな場合だろうか。
  • よくあるパターンは下記のような書き方をした場合だ。(本当は depssetCount も入れなければならないのだが、今回説明したいことの本筋ではないので説明しやすさのため省略している。)
const Component1: React.FC = () => {
  const [count, setCount] = useState(0);

  const hoge = {};

  useEffect(() => {
    // 無限ループする
    setCount(n => n + 1);
    console.log(hoge);
  }, [hoge]);

  return <div>count: {count}</div>;
};

const Component2: React.FC = () => {
  const [count, setCount] = useState(0);

  const fuga = () => {
    console.log('fuga');
  };

  useEffect(() => {
    // 無限ループする
    setCount(n => n + 1);
    fuga();
  }, [fuga]);

  return <div>count: {count}</div>;
};
  • const hoge = {};const fuga = () => { console.log('fuga') }; は、一見すると不変の値のように見える。そのため「前回レンダリング時と値が変わっていないのに、なぜ無限ループしてしまうのか」と思われるかもしれない。
  • そこで JavaScript が値を比較する方法について少し掘り下げてみようと思う。ブラウザの Console を開いて、下記のように入力してみよう。
var foo = 123;
foo === foo;
// => true

foo === 123;
// => true

123 === 123;
// => true

var hoge = {};
hoge === hoge;
// => true

hoge === {};
// => false

{} === {};
// => false
  • 値が数字だった場合は直感通りの結果が返ってきているが、オブジェクトの場合は少し期待と違ったのではないだろうか。
  • これは JavaScript がオブジェクト同士を === で比較する際、内部に格納されている値ではなく、「参照先が同じであるかどうか」を元に比較しているために起きている。
  • hoge === hoge は両方とも同じオブジェクトを参照しているので true が返っているが、 hoge === {} は後半の {} がこの場で新しく生成されたオブジェクトであるため hoge とは異なるオブジェクトであり、 false が返っている。
  • {} === {} も全く同じで、ソースコード上に {} と直書きした場合はその行が実行された時点で新しいオブジェクトが生まれるため、前半の {} も後半の {} も「異なるオブジェクトである」と判断され、 false が返る。
  • この前提知識を元に、先ほどの useEffect を再度眺めてみよう。
const Component1: React.FC = () => {
  const [count, setCount] = useState(0);

  const hoge = {};

  useEffect(() => {
    // 無限ループする
    setCount(n => n + 1);
    console.log(hoge);
  }, [hoge]);

  return <div>count: {count}</div>;
};
  • const hoge は Component 関数が実行される度に新しく生成されるオブジェクトである。
  • Component 関数は、そのコンポーネントもしくはその上位のコンポーネントの state が更新される度に実行される。
  • 従ってレンダリングの度に hoge が再生成されるため、 useEffect は「前回レンダリング時のオブジェクトと異なるオブジェクトが渡されてきた」と判断される。
  • useEffect の内部で呼ばれている setCount(n => n + 1) は「現在の count の値に1を足して更新する」という処理である。
  • 従って、初回レンダリング後に setCount(n => n + 1) によって count が更新される→2回目のレンダリングが発生する→2回目のレンダリング後に再度 useEffect が setCount(n => n + 1) を実行する → また count が更新されるため3回目のレンダリングが発生する → useEffect が setCount(n => n + 1) を実行する… という流れで無限ループになる。

無限ループしないパターン

  • では無限ループさせないためにはどうしたらいいのか。下記のような書き方で「前回レンダリング時と同じオブジェクトなので、 effect を実行しなくて良い」と useEffect に伝えることができれば OK だ。
const bar = {};

const Component: React.FC = () => {
  const [count, setCount] = useState(0);

  const hoge = useMemo(() => {
    return {};
  }, []);

  useEffect(() => {
    // 無限ループしない
    setCount(n => n + 1);
    console.log(hoge);
  }, [hoge]);

  const fuga = useCallback(() => {
    console.log('fuga');
  }, []);

  useEffect(() => {
    // 無限ループしない
    setCount(n => n + 1);
    fuga();
  }, [fuga]);

  const [foo, setFoo] = useState({});

  useEffect(() => {
    // 無限ループしない
    setCount(n => n + 1);
    console.log(foo);
  }, [foo]);

  useEffect(() => {
    // 無限ループしない
    setCount(n => n + 1);
    console.log(bar);
  }, [bar]);

  return <div>count: {count}</div>;
};
  • useMemouseCallback は useEffect と同様に、第一引数に関数を取り、第二引数に依存する値の配列 deps を受け取る。
useMemo(factory, deps)
useCallback(callback, deps)
  • どちらも機能はほぼ同じで、まず useMemo は初回レンダリング時に第一引数の関数 factory の返り値をキャッシュしておく。その後レンダリングの度に deps に渡した値を前回レンダリング時と比較して、値が同じだった場合は前回レンダリング時にキャッシュしておいた返り値を返し、値が異なっていた場合には再度 factory を実行し、その返り値を渡す。
  • useCalllback は、第一引数の callback 自体を初回レンダリング時にキャッシュし、以降は deps の値が変わらなければ前回キャッシュした関数をそのまま渡す。
  • こうすることで deps の中身が変わらなければ前回と同じオブジェクト・関数を取得することができるので、 useEffect の deps に入れても無用な無限ループを起こさなくて済むようになる。
  • また useState を介して受け取る値も React 側にキャッシュされているため、オブジェクトであっても useEffect の deps に入れて問題ない。上記の const [foo, setFoo] = useState({}); の場合は、 setFoo が呼ばれるまで foo は同じオブジェクトがキャッシュされ続ける。

余談

  • useState によって生成される setter 関数(SetStateAction)も useCallback と同じ理論で React にキャッシュされているので、特に改めて useCallback 等を介する必要なく useEffect の deps に入れてしまって問題ない。
const [foo, setFoo] = useState({});

const cachedSetFoo = useCallback(setFoo, []); // こういうことはしなくていい

useEffect(() => {
  setFoo({ foo: 123 });
}, [setFoo]); // そのまま deps にぶち込んでも無限ループしない

まとめ

結論をまとめると、下記の条件が揃うと無限ループが発生する。

  • useEffect の deps に、毎回のレンダリングの度に異なる値と見なされるオブジェクトや関数などが含まれている。
  • その際に実行される effect の中に state が更新するような処理が含まれており、2回目、3回目のレンダリングを発生させている。

deps に渡す「依存する値」とは?

  • よく「useEffect の第二引数 deps に何を渡せば良いのか、何を渡さなくて良いのか」について説明したくなるシーンがあるが、長くなるので予めまとめておきたい
  • しかし今日はちょっともう気力が限界なので、あとでこの記事を更新する形で追記するということにしたい。
82
59
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
82
59