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

はじめに

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

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

useEffectと一緒に使われることも多いuseLayoutEffectと元に、その実行条件と実行タイミングについて考察する。

本題に入る前に …

以前の公式ドキュメントでは、「副作用 (effect) フック により、関数コンポーネント内で副作用を実行することができるようになります」とだけ書かれていて、「副作用って意図しない動作ってこと? なんでそんなものが必要なの?」と、筆者としてはかなり混乱してしまった。その理解のためにReactコンポーネントの純粋性や副作用との関係について考察してみたので、参考までに …

備忘録: Reactコンポーネントの純粋性と副作用について理解する
Reactではコンポーネントは純粋 (Pure) であることが求められる。純粋であるとは具体的にはどういうことか、またなぜ純粋性が求められるのか、そして副作用との関係は、といった疑問について考察してみた。

レンダリングサイクル

Reactのコンポーネントによる描画 (レンダリング) の流れを下図に示す。

コンポーネントは、関数として描画のためのJSX (JavaScript XML) を返すが、そのあとにReactのランタイムで複数のステップが実行される。

このランタイムの処理の中で、useLayoutEffectとuseEffectのコールバックが実行される。useLayoutEffectのコールバックは画面描画の前に、useEffectのコールバックは画面描画の後に実行される。

また、useLayoutEffectのコールバックの実行までは同期的に行われる。画面描画よりあとは非同期的に行われる。(JavaScriptの内部的には異なるマクロタスクで実行される)

useLayoutEffectは、描画前に実行されることから、レイアウトの調整など、描画に直接影響する処理の実装に使われる。useEffectは描画後に非同期に実行されることから、外部システムとのインタフェースなどの重い処理に使われる。

コンポーネントの実行

コンポーネントはJavaScriptの関数であり、次のトリガーで実行される。これらのトリガーは、コンポーネントの生成するJSXになんらかの変更をもたらすものと考えられる。

  • 上位のコンポーネントのレンダリングで呼び出されたとき
    • memo化されていてその引数に変更のない場合は実行されない (JSXの生成に影響しないため)
  • コンポーネントのステートが変更されたとき
    • 変更の直接のトリガーは、マウスなどのUIイベント、ネットワークI/Oの完了イベントなど
  • コンポーネントから参照されるコンテキストが更新されたとき

コールバックの登録

useEffectやuseLayoutEffectは関数のトップレベルに記述され、コンポーネントが実行されると、これらも毎回実行される。

useEffectやuseLayoutEffectは、そのコールバックをReactのランタイムに登録する。

Reactのランタイムの内部では、この登録は呼び出しのトリガーの違いで処理がことなる。

生成されたJSXが仮想DOMに追加された時 (mount時) に、Hookのスロットが作成され、コールバックが登録される。この仮想DOMへの追加は以下のときに発生する。

  • コンポーネントが初めて呼び出されたとき
  • 以前登録されたJSXが削除され (unmount) たあとにもう一度登録されたとき
  • コンポーネントのキーが変更されたとき
    • 古いキーのコンポーネントがunmountされ、新しいキーのコンポーネントがmountされたと見なされる

これら以外の実行時には、Hookのスロットが更新される。ただし、通常コールバック自体は変更されないのでプログラム側から見た違いはない。

コールバックの実行条件

前述したように、useEffectやuseLayoutEffectのコールバックは、レンダリングサイクルの中で呼び出される。この呼び出しの条件を指定することができる。

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

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

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

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

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

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

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

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

  return <> ... </>;
}

useEffectとuseLayoutEffectの使い分け

先述したように、useLayoutEffectは描画に関する処理に、useEffectは外部I/Oなどの処理に使うべきである。ただし、外部I/Oの結果、ステートが更新され、描画内容が変更される場合もある。このような場合には、どちらを使うべきか注意する必要がある。

結論としてはもしコールバックで実行する処理が同期処理でかつ軽いものであれば、useLayoutEffectを使うほうがよい。もし非同期処理であるか、同期処理でも重いものであれば、useEffectを使う方がよい。

フリッカーの発生

レンダリングサイクルの中で画面が描画されたあとに、useEffectのコールバックでステートが更新されると、新たなレンダリングサイクルが開始される。その中で画面を更新することで、短時間に表示が変化することになり、フリッカーが発生する。

このような場合、useEffectのコールバックの処理をuseLayoutEffectのコールバックに移すことで、フリッカーを防止できる。

useLayoutEffectのコールバックでステートを更新すると、ステートの更新をトリガーとする新しいレンダリングサイクルを開始する。つまりステートが更新されJSX/仮想DOM/DOMに反映されるまで描画されないからである。

ただし、コールバックで実行する処理が非同期処理の場合は、その完了前にコールバックの処理が終了して、ブラウザが描画を開始してしまう。また同期処理の場合でも、コールバックの処理に時間がかかると、描画がブロックされるため表示が遅い (あるいはカクカクする) UIとなってしまう。また、マウスやキーボードにも反応しないため操作性も悪くなる。

このような場合、useEffectで非同期処理をするべきである。チラツキの防止は、I/O処理中はビジー画面を出すなど別の方法をとる必要がある。

DOMの直接更新

Reactの設計思想として、UIの更新はコンポーネントの引数、ステート、コンテキストの更新を通して行われる。DOMを直接更新することは技術的には可能だが、避けるべきとされている。もしDOMを直接更新したい場合は、フリッカーの発生を防ぐために、useLayoutEffectのコールバックで行うべきである。

複数のuseEffectの使用

useEffectやuseLayoutEffectは複数定義できる。実行条件ごとに独立したuseEffectやuseEffectLayoutを定義することができる。

コールバックの後処理

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

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

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

次のサンプルでは、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

更新記録

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