備忘録: React useEffectの実行条件と実行タイミング

はじめに

ReactのHookの中でも使用頻度の高いuseEffect。サンプルなど見ながら何となく使ってきたが、それでは心許なくなってきた。そこで公式ドキュメントを読みながら自分の理解を整理してみた。

筆者なりのuseEffectの理解は「Reactコンポーネントの中で、何らかの処理を、特定の条件で、特定のタイミングで、コールバックとして実行するための仕組み」というもの。何らかの処理は自分が書くものなので良いとして、その実行条件と実行タイミングについては、どのようなものか理解しておく必要がある。

なぜサイドエフェクトなのか?

いきなり脱線気味だが、筆者はこれが気になってしょうがない。

ReactのドキュメントにはuseEffectについて次のように書かれている。

The Effect Hook lets you perform side effects in function components:

これを初めて読んだときには正直「んんんっ?」となってしまった。「副作用? 意図しない動作ということ?」「サイドがあるならメインがあるはずだけど、この場合のメインってなんだろう?」

疑問なのは筆者だけ? でも名は体を現すとも言うし、名前の意味 (意図) が理解できないと、その後の説明が頭に入ってこない …

これについては次のように理解した。メインは、コンポーネントの呼び出しでJSXを返す処理そのもの。useEffectのコールバックでは、直接JSXを返すことはない。その代わりに外部のシステムとインタラクションしたり、その結果ステートを更新したりする。これら直接JSXを返す以外の処理を副作用とみなす。

あまりしっくりこないけど、まあ当たらずとも遠からずだろうと思いたい …

参照: A Guide to React Rendering Behavior

最近下記の定義を見つけた。ただ「エフェクト」の定義に「副作用」が使われているので、やはりしっくりこない。

エフェクトは、特定のイベントによってではなく、レンダー自体によって引き起こされる副作用を指定するためのものです。(https://ja.react.dev/learn/synchronizing-with-effects)

コールバックの登録

useEffectは、Reactコンポーネントの関数定義の中に書かれる。つまりコールバックの登録は、Reactコンポーネントが呼び出された時に行われる。(当たり前といえば当たり前だが …)

ならばReactコンポーネントはいつ呼び出されるのか。それは下記のタイミングとなる。

  • 上位のコンポーネントから呼び出された時
    • 上位のコンポーネントのJSX定義でネストされている
  • コンポーネントのステートが変更された時
    • マウスなどのUIイベント、ネットワークI/Oの完了イベントなど

他にはコンテキストの更新などもトリガーとなる。

コールバックの実行タイミング

useEffectに登録したコールバックが実行されるのは画面の描画後となる。

ドキュメントと簡単なテストプログラムから、レンダリングは次のようなサイクルで実行されていると推測した。 この後で説明するuseLayoutEffectについても合せて記載した。(公式ドキュメントで確認できた訳では無いので、大間違いしている可能性もある …)

プログラムコードの中で行うのはJSXの生成まで。そのあとはReactのランタイムの中で処理される。useEffectや後述するuseLayoutEffectで登録されるコールバックの実行はランタイムの中である。

コールバックの実行条件

前述したように、useEffectのコールバックが実行されるのは、Reactコンポーネントが呼び出され、Reactによる画面の描画が行われたあとになる。ただし毎回の描画後に必ず呼び出される訳ではなく、呼び出しの条件を指定することができる。

  • 毎回必ず
  • 初めてコンポーネントが呼び出されたとき (初回限り)
  • 指定した条件のとき

「毎回必ず」は要注意。コールバックでステートを変更すると、それにより再描画がおこり、コールバックが再び呼び出される。もしステートの更新が続くとコールバックの呼び出しが無限ループとなってしまう。これは「指定した条件のとき」も、同様のことが起こり得る。

条件の指定は、useEffect()を呼び出す時の第2引数に指定する。

  • 何も指定しなければ毎回必ず
  • 空の配列を指定すると初回限り
  • 空でない配列を指定すると、その何れかの要素が更新されたとき
    • 前回 useEffect()が呼び出された時に渡された値と異なるとき
      • 要素を監視している訳ではないので、useEffect()の呼び出し後の変更は検知されない
    • 要素にオブジェクトを指定した場合、その中身の変更は検知されない (JavaScriptのオブジェクトとして同値であれば変化なしと見做される)

にそれぞれコールバックが呼び出される。

function MyComponent() {
  useEffect(() => {
    // 毎回呼ばれる
  });

 useEffect(() => {
    // 初回のみ呼ばれる
 }, []);

  useEffect(() => {
    // someConditionに変更があった時のみ呼ばれる
  }, [ someCondition ]);

  return <> ... </>;
}

コールバックからのUIの更新

useEffectのコールバックでは、JSXによるUIを直接*変更できないが、DOMを直接操作することでUIを変更できる。ただしReactの設計思想として、DOMの直接操作はできるだけ避けるべしとなっている。

* 直接は変更できなくても、後述するようにステートを更新することで、間接的に変更することはできる

フリッカーの発生

useEffectでDOMを直接更新すべきでない理由のひとつとしてフリッカーの発生がある。コンポーネントの通常処理で画面が描画されたあとに、更に画面を更新することで、短時間に表示が変化することになり、それがフリッカーとなる。このフリッカーを防ぐには、後述するuseLayoutEffectを使用する。

ステートの更新によるUIの更新

「useEffectのコールバックではJSXによるUIの変更はできない」と書いたが、実際にはコールバックの処理結果で画面を更新したい場合がある。その場合はステートを更新して、コンポーネントの再呼び出しを行わせる。その呼出の中で、コールバックの処理結果 (例えばサーバーから取得したデータ) を使ってJSXを生成すればよい。

useLayoutEffectの使用

DOMを直接操作してUIを更新したい場合は、useLayoutEffectを使用する。useLayoutEffectはuseEffectとよく似ているが、違いは呼び出されるタイミングにある。

useLayoutEffectのコールバックは、JSXからDOMを構築・更新したあと、描画の前に呼び出される。useEffectと違い、描画前にDOMを変更できるため、フリッカーを生じることがない。DOM上の要素はリファレンス (useRef) を使ってアクセスする。

useLayoutEffectのコールバックは、その処理が終了するまで、画面が描画されない (ブロックされる)。マウスやキーボードにも反応しない。このためコールバックの処理に時間がかかると、遅いUIになってしまうので注意する。

複数のuseEffectの使用

useEffectは複数定義できる。useEffectの実行条件ごとに独立したuseEffectを定義したり、処理内容に応じて複数のコールバックを用意したりできる。

コールバックの後処理

useEffectのコールバックで何らかの処理を開始して、その後始末をしたい場合がある。この場合は、コールバックから後処理の関数 (以下クリーンアップ) を返してやればよい。クリーンアップは、次のサイクルでコールバックが呼ばれる前に実行される。つまり何らかのトリガーで再描画が発生した場合に実行される。もし再描画が発生しなければ、クリーンアップは実行されない。ただし最後にコンポーネントが破棄される時はクリーンアップが実行される。

クリーンアップが何時呼ばれるかは、次のトリガーの発生次第なので、一定時間内に確実に実行する必要のある処理 (例えばリモートリソースに対するセッションのクローズ) は、クリーンアップには記述できない。

この点では後処理というよりも、新しくコールバックを呼び出す前処理として、以前のコールバックの残滓をクリアする、と位置付けた方がしっくりくるかも。

次のサンプルでは、SubComponent1とSubComponent2の2つのコンポーネントが定義されており、対応するボタンをクリックすることで、表示するコンポーネントを切り替えている。

それぞれのコンポーネントでは、useEffectが呼び出され、そのコールバックはクリーンアップを返すようになっている。

const useState = React.useState;
const useEffect = React.useEffect;
const useLayoutEffect = React.useLayoutEffect;

function App() {
  const [select1, setSelect1] = useState<boolean>(true); 
  
  console.log('Rendering App - ', (select1 ? 'SubComponent #1' : 'SubComponent #2'));
  
  return (
    <>
      <button onClick={(e) => setSelect1(true)}>SubComponent #1</button>
      <button onClick={(e) => setSelect1(false)}>SubComponent #2</button>
      <br /><br />
      {select1 && <SubComponent1 />}
      {!select1 && <SubComponent2 />}
    </>
  );
}

function SubComponent1() {
  console.log('Rendering SubComponent #1');
  
  useEffect(() => {
    console.log('> useEffect #1');
    return () => {console.log('> Cleanup SubComponent #1')};
  });
  
  useLayoutEffect(() => {
    console.log('> useLayoutEffect #1');
  });
  
  return (
    <>
      <p>SubComponent #1</p>
    </>
  );
}

function SubComponent2() {
  console.log('Rendering SubComponent #2');
  
  useEffect(() => {
    console.log('> useEffect #2');
    return () => {console.log('> Cleanup SubComponent #2')};
  });
  
  useLayoutEffect(() => {
    console.log('> useLayoutEffect #2');
  });
  
  return (
    <>
      <p>SubComponent #2</p>
    </>
  );
}

ReactDOM.render(<App />, document.getElementById("root"));

結果は次のようになる。各回のレンダリングで、対象のコンポーネントの描画に先立ち、前回のレンダリングに対するクリーンアップが実行されている。

"Rendering App - " "SubComponent #1"
"Rendering SubComponent #1"
"> useLayoutEffect #1"
"> useEffect #1"

// ボタンSubComponent #2をクリック
"Rendering App - " "SubComponent #2"
"Rendering SubComponent #2"
"> useLayoutEffect #2"
"> Cleanup SubComponent #1"
"> useEffect #2"

// ボタンSubComponent #1をクリック
"Rendering App - " "SubComponent #1"
"Rendering SubComponent #1"
"> useLayoutEffect #1"
"> Cleanup SubComponent #2"
"> useEffect #1"

async/await

useEffectのコールバックでasync/awaitを使うには次のように記述する。

useEffect(() => {
  doSomething(...);
}

async doSomething(...) {
  await doAsync(...);
}

筆者はTypeScriptを使っており、最初下記のように記述したが、useEffectはPromiseの戻り値を受け付けないと、TypeScriptに怒られてしまった。

// エラーが発生する書き方
useEffect(async () => {
  await doAsync(...);
}

JavaScriptであれば、上記の書き方でもエラーにはならない。

不必要なuseEffectの利用を回避

下記の記事には、useEffectを使う必要のないケースについての解説がある。”Removing unnecessary Effects will make your code easier to follow, faster to run, and less error-prone.” とのこと。

You Might Not Need an Effect – React
The library for web and native user interfaces

その他 (雑感)

筆者の場合、リモートサービスからのデータの読み出しコードをuseEffectに書くことが多い。ただこれは非同期処理なので、画面の描画後という起動タイミングは重要ではない。起動タイミングだけを見ればメインロジックに記述しても問題ない。ただ起動条件の設定は、useEffectを使うと簡単にできるので、useEffectを使うメリットを感じている。

更新記録

日付内容
2023/09/15「不必要なuseEffectの利用を回避」を追加
2023/09/12コールバックの後処理のサンプルを置き換え
2023/08/15文言微調整
2023/06/01サイドエフェクトの意味についての考察を更新
コンポーネントが廃棄される際にもコールバックの後処理が行われることを追記
2022/12/07コールバックの実行条件のうち「指定した条件」について補足
2022/11/10初版リリース
React備忘録
スポンサーリンク