🚰

React17におけるuseEffectの破壊的変更を理解する

2020/09/19に公開

しばらく前、React 17 RCが発表されました。現行のReact 16に比べて、いくつかの破壊的変更がある一方、新機能が何もないというのが特徴です。Reactチームとしては、新機能が無いとはいえ、破壊的変更も少なくなっておりなるべく16から17へのアップデートを行なってほしいという考えのようです。

この記事では、React 17における破壊的変更のうち、useEffectのクリーンアップのタイミングに関する変更を取り上げます(以下は公式サイトから引用)。

In React 17, the effect cleanup function also runs asynchronously — for example, if the component is unmounting, the cleanup will run after the screen has been updated.

(筆者による翻訳) React17では、effectクリーンアップ関数も非同期的に実行されるようになります。例えば、コンポーネントがアンマウントされるとき、クリーンアップ関数は画面が更新_された後_に実行されます。

React 16からReact 17以降への移行をスムーズにするために、たとえいますぐReact 17にアップデートするつもりが無いとしても、React 17に適合した書き方をするのが得策です。そこで、この記事ではuseEffectのクリーンアップ関数の実行タイミングについて詳しく学びます。

useEffectの復習

useEffectはフックの一種であり、コンポーネントがレンダリングされたときに実行される関数を指定します。例えば次のコンポーネントFooは、レンダリングされるたびにuseEffectで指定された関数が実行され、コンソールにfoo!と表示するでしょう。より具体的には、まず最初にFooがレンダリングされたとき(マウントされたとき)にfoo!と表示し、その後Fooが再レンダリングされるたびにfoo!と表示します。

const Foo = () => {
  useEffect(() => {
    console.log("foo!");
  });
  return <p>I am foo</p>;
};

useEffectに第2引数を渡すことで関数が実行されるタイミングを制御できるのはご存知の通りです(まだご存知でない方は公式ドキュメントなどを通じて学習しましょう)。特に、第2引数に[]を指定することによって、コンポーネントがマウントされたときに1回だけ関数を実行することが可能です。

const Foo = () => {
  useEffect(() => {
    console.log("Fooがマウントされました!");
  }, []);
  return <p>I am foo</p>;
};

useEffectのタイミング

useEffectの特徴は、関数が呼ばれるのがレンダリングが画面に反映された後であるということです。

これにより、useEffectのコールバック関数の中では、DOMを見れば自身によってレンダリングされた物を観察できます(react-domを使用している場合。React Nativeの場合はDOMではありませんが、ここではDOMの場合で話を進めるので適宜読み替えてください)。Reactではrefを用いると生のDOMオブジェクトを取得でき、useEffectの関数内ではすでにこれが利用可能な状態になっています。このことは次のような例で確かめられるでしょう。

const Foo = () => {
  const pRef = useRef();
  useEffect(() => {
    // "I am foo" と表示される
    console.log(pRef.current.textContent);
  }, []);
  return <p ref={pRef}>I am foo</p>;
};

この例では、Fooによってレンダリングされたp要素のDOMオブジェクトがpRef.currentに入ります。useEffectのコールバック関数内ではこの処理がすでに完了しているのでpRef.currentが利用可能です。上の例では、そのtextContentプロパティ(中のテキストを文字列で取得する)を使用しています。Fooによって<p>I am foo</p>がレンダリングされたので、pRef.current.textContent"I am foo"となっています。

あまり勧められたことではありませんが、useEffectからDOMに干渉することもできます。例えばpRef.current.textContentを書き換えることもできるでしょう。

const Foo = () => {
  const pRef = useRef();
  useEffect(() => {
    pRef.current.textContent = "I am not foo";
  }, []);
  return <p ref={pRef}>I am foo</p>;
};

このFooコンポーネントは<p>I am foo</p>をJSXによりレンダリングしますが、useEffectpの中身をI am not fooに書き換えているため、画面にはI am not fooと表示されます。ただし、useEffectのコールバック関数はJSXの内容が画面に反映されてから呼び出される関係上、実際にやってみると一瞬だけI am fooが見えます。

ただし、useEffectの代わりにuseLayoutEffectを使うとこの一瞬の隙を塞ぐことができます。このことから、コールバック関数で画面に影響を与える場合はuseLayoutEffectが適しています(上の例のようにtextContentを書き換えるようなことは意味がないのでやめるべきですが)。逆に言えば、useEffectの処理は画面に影響を与えない場合に適しています(例えばイベントハンドラを付加するとか)。Reactは、useEffectの処理(画面に影響を与えないはず)の実行を、レンダリング結果のDOMへの反映および画面への反映より後にすることによって、レンダリング結果が画面に反映されるまでの時間を短くしているのです。

このことは、コールバック関数の中で時間のかかる処理をしてみると分かります。例えば、次の例は1秒間ループし続けるrun1secという悪い関数を定義し、useEffectのコールバック関数の中で使っています。

const Foo = () => {
  const pRef = useRef();
  useEffect(() => {
    run1sec();
    pRef.current.textContent = "I am not foo";
  }, []);
  return <p ref={pRef}>I am foo</p>;
};

function run1sec() {
  const start = performance.now();
  while (performance.now() - start < 1000);
}

この状態でFooをレンダリングすると、まずI am fooと表示される→1秒後にI am not fooになる、という挙動が観察できます。これにより、useEffectのコールバック関数がレンダリング結果がDOMに反映され、画面にもされた後に実行されていることがよく分かります。

一方、useLayoutEffectに替えてみましょう。

const Foo = () => {
  const pRef = useRef();
  useLayoutEffect(() => {
    run1sec();
    pRef.current.textContent = "I am not foo";
  }, []);
  return <p ref={pRef}>I am foo</p>;
};

こうすると、Fooをレンダリングしても最初は何も表示されません。1秒後になってI am not fooと表示されます。このことから、I am fooがDOMに反映後まだ画面に反映されていないタイミングでuseLayoutEffectのコールバックが実行されていることが分かります。まとめると、useEffectuseLayoutEffectのコールバック関数の実行のタイミングは以下のように整理できます。

  • useEffect: レンダリング→レンダリング結果をDOMに反映→DOMを画面に反映→コールバック関数を実行
  • useLayoutEffect: レンダリング→レンダリング結果をDOMに反映→コールバック関数を実行→DOMを画面に反映

先の例からも分かるように、useLayoutEffectのコールバック関数に時間がかかる場合、レンダリングが画面に反映されるまでの時間に影響します。一方、useEffectなら影響しません。このことから、レンダリング結果がなるべく早く画面に反映されるように、なるべくuseLayoutEffectではなくuseEffectを使わなければなりません。

さて、すでに何か勉強した気になったかもしれませんが、これはReact 17と特に関係ない話なのでまだ復習です。

useEffectのクリーンアップ関数 (React 16の場合)

これまで扱ってきたuseEffectのコールバック関数は、コンポーネントがマウントされたときや再レンダリングされたときに実行されました。それと対になるものとして、コンポーネントがアンマウントされる直前、または次のコールバック関数が呼び出される直前に呼び出される関数をuseEffectはサポートしています。これがクリーンアップ関数です。クリーンアップ関数は、コールバック関数が関数を返り値として返すことで指定できます。言い方を変えると、クリーンアップ関数を作るのはコールバック関数であり、これがコールバック関数とクリーンアップ関数を対になるものとしています。

const Foo = () => {
  useEffect(() => {
    // ここがコールバック関数
    console.log("Fooがマウントされました!");
    // ↓これがクリーンアップ関数
    return () => {
      console.log("Fooがアンマウントされる!");
    };
  }, []);
  return <p>I am foo</p>;
};

このコンポーネントFooは、マウントされたときにFooがマウントされました!と表示し、アンマウントされるときにFooがアンマウントされる!と表示するでしょう。

もう一つ例をみてみましょう。

const Foo = () => {
  useEffect(() => {
    // ここがコールバック関数
    console.log("foo!");
    // ↓これがクリーンアップ関数
    return () => {
      console.log("bye!");
    };
  });
  return <p>I am foo</p>;
};

今度の例はuseEffectの第2引数が無いので、レンダリングのたびにコールバック関数が呼ばれます。クリーンアップ関数は、次のコールバック関数が呼ばれる前(またはFooがアンマウントされる前)に呼び出されます。Fooを最初にマウントしたときfoo!と表示され、次にFooが再レンダリングされたときはbye!foo!の順に表示されます。最後にFooがアンマウントされるときはbye!だけ表示されます。

ここに、さらにDOMの反映や画面の反映の話が関わってきます。レンダリング・コールバック関数・クリーンアップ関数に関してまとめると次のような流れとなります。まず、最初のレンダリング(レンダリング1)が発生したときは、すでに説明したように次の流れとなります。

  • レンダリング1 → レンダリング1の結果がDOMに反映 → DOMが画面に反映 → コールバック関数1

このコールバック関数1がクリーンアップ関数1を返したとしましょう。すると、次のレンダリング(レンダリング2)が発生したときは次の流れとなります。

  • レンダリング2 → レンダリング2の結果がDOMに反映 → クリーンアップ関数1 → DOMが画面に反映 → コールバック関数2

このことから分かることは、クリーンアップ関数1で長い処理を実行することで、レンダリング2の結果の画面への反映を遅らせることができるということです。試しにクリーンアップ関数で先ほどのrun1secを使ってみましょう。

const Foo = () => {
  useEffect(() => {
    return () => {
      run1sec();
      console.log("bye!");
    };
  });
  return <p>I am foo</p>;
};

function run1sec() {
  const start = performance.now();
  while (performance.now() - start < 1000);
}

こうすると、レンダリング2の結果が画面に反映されるのを1秒遅らせることができます。実際、Fooを再レンダリングしたりアンマウントしたりすると、それが画面に反映されるまで1秒かかるでしょう。

なお、クリーンアップ関数が呼ばれるタイミングは、「コンポーネントが再レンダリングされた場合」と「コンポーネントがアンマウントされた場合」で少し異なります。「次のレンダリングが発生した場合」、すなわちレンダリング2の後もFooはマウントされたままである(かつFooも再レンダリングされる)場合は先ほどの再掲ですが以下のようになります。

  • レンダリング2 → レンダリング2の結果がDOMに反映 → クリーンアップ関数1 → DOMが画面に反映 → コールバック関数2

一方、レンダリング2の結果としてFooが画面から消える(アンマウントされる)場合もあります。その場合は次の流れとなります。

  • レンダリング2 → クリーンアップ関数1 → レンダリング2の結果がDOMに反映 → DOMが画面に反映

つまり、アンマウントされる場合はレンダリング2の結果がDOMに反映される前(言い換えればDOMにまだFooの中身が残っている状態)に実行されるのです。このことに気づかずにReactを使っている方も多いと思いますが、改めて示されると何だか不整合がある感じがしますね。

React 17におけるクリーンアップ関数のタイミングの変更

実は、React 17ではクリーンアップ関数のタイミングが変更され、同時に上述の不整合も解消されます。これがuseEffectの破壊的変更です。React 16とReact 17を比較すると次のようになります。

再レンダリング後もコンポーネントが残っている場合

  • React 16: レンダリング2 → レンダリング2の結果がDOMに反映 → クリーンアップ関数1 → DOMが画面に反映 → コールバック関数2
  • React 17: レンダリング2 → レンダリング2の結果がDOMに反映 → DOMが画面に反映 → クリーンアップ関数1 → コールバック関数2

アンマウントされる場合

  • React 16: レンダリング2 → クリーンアップ関数1 → レンダリング2の結果がDOMに反映 → DOMが画面に反映
  • React 17: レンダリング2 → レンダリング2の結果がDOMに反映 → DOMが画面に反映 → クリーンアップ関数1

主に、React 17では2つの変更が入ったと見ることができます。第一に、クリーンアップ関数はレンダリング2の結果の新しいDOMが画面に反映されてから呼ばれるようになります。これにより上の例でコールバック関数1がrun1secを実行しても、React 17ならばその前に画面への反映が済んでいるので、影響がありません。この変更の利点として、上の例から明らかなように、クリーンアップ関数の実行に時間がかかってもレンダリング結果の画面への反映が遅れないようになります。この違いを確かめられるCodeSandboxを用意しました。React 16をReact 17に変えることで挙動の違いを実感できます。

第二に、React 17ではコンポーネントがアンマウントされる場合も同様に、DOMが画面に反映された後にクリーンアップ関数が呼び出されるように変更されます。この場合は特に、クリーンアップ関数が呼ばれる際のDOMの状態が異なります。従来はレンダリング1がDOMに反映された状態でクリーンアップ関数が呼び出されていましたが、React 17ではレンダリング2がDOMに反映された状態で呼び出されます。

この変更により、アンマウントか否かによってコールバック関数が呼び出される際のDOM状態が異なるという問題が解消され、React 17は常に「次のレンダリング後」(レンダリング2の後)のDOM状態でクリーンアップ関数が呼び出されることになります。

破壊的変更の影響を受ける例

この第二の変更が問題となるのは次のような場合です(Reactの公式サイトにも似たような例が挙げられていますが、ここではより具体的な例を紹介します)。

const Foo = () => {
  const pRef = useRef();
  useEffect(() => {
    const handler = (e) => {
      console.log("hi!");
    };
    pRef.current.addEventListener("click", handler);
    return () => {
      pRef.current.removeEventListener("click", handler);
    };
  }, []);
  return <p ref={pRef}>I am foo</p>;
};

この例では、useEffectを用いて<p>にclickイベントを設定しています(本当は<p onClick={...}>とした方がいいのですが、例なので大目に見てください)。このように、useEffectのコールバック関数の中でイベントハンドラを設定した場合、クリーンアップ関数でそれを消すというのが典型的なパターンです。useEffectの第2引数が[]なので、このコンポーネントがマウントされたときにイベントハンドラを設定し、アンマウントされるときにイベントハンドラを削除します。

このコンポーネントはReact 16ではうまく動作しますが、React 17では動作しません。その理由は、React 17ではクリーンアップ関数の中ではpRef.currentnullとなっているからです。React 17ではコンポーネントがアンマウントされるときもレンダリングがDOMに反映されてからクリーンアップ関数が呼び出されるのでした。つまり、このクリーンアップ関数が呼び出された際に、すでに<p>は(画面の)DOMツリーから除去されているのです。Reactのref={pRef}という機能を使っている場合、pがDOMツリーから除去された時点でpRef.currentnullに設定されます。

回避策は、コールバック関数の時点でpRef.currentの内容を変数に退避しておくことです。次の例では変数ppRef.currentを入れてそれをクリーンアップ関数でも使っています。

const Foo = () => {
  const pRef = useRef();
  useEffect(() => {
    const handler = (e) => {
      console.log("hi!");
    };
    const p = pRef.current;
    p.addEventListener("click", handler);
    return () => {
      p.removeEventListener("click", handler);
    };
  }, []);
  return <p ref={pRef}>I am foo</p>;
};

クリーンアップ関数の時点で<p>がDOMツリーから除去されている点は変わりませんが、pRef.currentに頼らずpという変数に<p>を表すDOMオブジェクトを入れておくことによって、DOMツリーから除去された後のp要素のオブジェクトにアクセスすることが可能で、無事にp.removeEventListenerを呼び出すことができます。尤も、この例の場合DOMから除去されたpはもう使われないのでp.removeEventListenerを呼び出さずに放置してもいいのですが、いつそれをして良くていつそれをしてはならないのかを理解していない人が真似してしまうと困るので、どんな場合もきちんと後処理をするのも悪い選択肢ではありません。

クリーンアップ関数の実行順序について

Reactの公式サイトをよく読むと、React 17におけるクリーンアップ関数の実行順序について次のようなことが書いてあります。

Additionally, React 17 executes the cleanup functions in the same order as the effects, according to their position in the tree. Previously, this order was occasionally different.

(筆者による翻訳)さらに、React17はクリーンアップ関数を、コンポーネントツリーの中での位置に従って、エフェクト(訳注:useEffectのコールバック関数)と同じ順序で実行します。以前は、時折順序が異なる場合がありました。

これに関して筆者はいくつか実験してみたのですが、どうも書いてある通りの挙動にならないのでReactのGitHubリポジトリにissueを建ててみました。返事を待っているところです。

ただ、関連してReact 16と17の間で変化した点を発見したので紹介しておきます。
次のような並びでコンポーネントが並んでいて、それぞれが中でuseEffectを使っているとしましょう。

<Foo1 />
<Foo2 />
<Foo3 />

1回の再レンダリングでこれらのコンポーネントが全て再レンダリングされる(useEffectのコールバック関数が呼ばれる)場合、React 16での実行順序は以下の順となります。

  • Foo1のクリーンアップ関数1 → Foo1のコールバック関数2 → Foo2のクリーンアップ関数1 → Foo2のコールバック関数2 → Foo3のクリーンアップ関数1 → Foo3のコールバック関数2

一方、React 17では次の順序となります。

  • Foo1のクリーンアップ関数1 → Foo2のクリーンアップ関数1 → Foo3のクリーンアップ関数1 → Foo1のコールバック関数2 → Foo2のコールバック関数2 → Foo3のコールバック関数2

つまり、React 16では各コンポーネントに対して個別にクリーンアップ→次のコールバックという流れを実行していたのに対して、React 17ではまず全てのクリーンアップ関数を呼び出したあとに次のコールバックを全部呼び出すという流れとなっています。

まとめ

React 17で予定されている破壊的変更のひとつとして、useEffectのコールバック関数・クリーンアップ関数に対する変更を解説しました。

Reactの公式サイトでも言われている通り、破壊的変更といっても実際にアプリケーションに影響が出るケースは多くないでしょう。最後に見たコンポーネント間のエフェクトの順序に関する変更など、よほど黒魔術的なコードを書かないと影響を受けません。

そうはいっても、こういった機序を理解していなければuseEffectを用いたコードの正確な理解は困難ですから、ぜひ覚えておきましょう。

また、現在React 16を使用している方は、React 17の破壊的変更の影響を受けてしまうようなコードを書かないように気をつけましょう。

GitHubで編集を提案

Discussion