🤔

あれ?useEffectが2回実行されるのはなぜだ??

2025/02/08に公開

発生した事象

以下のような、親コンポーネントで子コンポーネントの表示/非表示を制御するようなコードで、
子コンポーネントを表示させた際に、コンソールに「子コンポーネントがマウントされました」と2回表示されたことから、
useEffectが2回実行されたことがわかった。

pages/index.tsx
import { useState } from "react";
import ChildComponent from "../components/ChildComponent";

export default function Home() {
  const [displayChild, setDisplayChild] = useState(false);

  return (
    <div className="p-4">
      <h1 className="text-xl font-bold">親コンポーネント</h1>
      <button
        className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
        onClick={() => setDisplayChild(!displayChild)}
      >
        {displayChild ? "子コンポーネントを非表示" : "子コンポーネントを表示"}
      </button>

      {displayChild && <ChildComponent />}
    </div>
  );
}

components/ChildComponent.tsx
import { useEffect } from "react";

const ChildComponent = () => {
  useEffect(() => {
    console.log("子コンポーネントがマウントされました");

    return () => {
      console.log("子コンポーネントがアンマウントされました");
    };
  }, []);

  return <div className="mt-4 p-4 bg-green-200">子コンポーネント</div>;
};

export default ChildComponent;

事象が起きた理由

ReactのStrictModeによるものです。

ReactのuseEffectの公式ドキュメントにも以下のように言及があります。

Strict Mode がオンの場合、開発時に React は実際のセットアップの前に、セットアップとクリーンアップをもう一度実行します。

ローカル環境でStrictModeではない状態で動作確認するには、
以下のような変更を加えてください。
※Next.jsを使用していることを想定しています。
ただし、後述するように、StrictModeを解除することはお勧めしません。

next.config.mjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true, // ここをfalseに変更する
};

export default nextConfig;

なぜ、StrictModeでは2回useEffectが実行されるのか

バグの発見に役立つためです。
Reactの思想として、コンポーネントを純粋に保たなくてはいけないというものがあります。
こちらの公式ドキュメントに記載のある通りです。

純粋に保つとは、コンポーネントに同じ入力があった場合は常に同じ挙動をしないといけないということです。

この思想に基づくと、useEffect内の処理はコンポーネントのレンダリングとは切り離すべきということになります。

適切にuseEffect内の処理をコンポーネントのレンダリングと切り離されていれば、
不自然な挙動を起こさずに済むというわけです。

不自然な挙動をするコードの例としては、以下のようなものがあります。
※StrictModeのみでコンポーネントが純粋ではないように見えるわけではないですが、
useEffect内の処理がコンポーネントのレンダリングとは切り離されていない例として作ってみました。

pages/index.tsx
import { useState } from "react";
import ChildComponent from "../components/ChildComponent";

export default function Home() {
  return (
    <div>
      <h1>非純粋なコンポーネント</h1>
      <ChildComponent id={1} />
      <ChildComponent id={2} />
      <ChildComponent id={3} />
    </div>
  );
}

components/ChildComponent.tsx
import { useEffect, useState } from "react";
let globalCounter = 0;

const ChildComponent = ({ id }: { id: number }) => {
  const [localCount, setLocalCount] = useState(0);
  const [globalCount, setGlobalCount] = useState(globalCounter);
  useEffect(() => {
    console.log("useEffect発動!");
    globalCounter += 1;
    setGlobalCount(globalCounter);
  }, []);

  const handleClick = () => {
    globalCounter += 1;
    setLocalCount(localCount + 1);
    setGlobalCount(globalCounter);
  };

  return (
    <div style={{ border: "2px solid red", padding: "10px", margin: "10px" }}>
      <h2>コンポーネント {id}</h2>
      <p>🔵 ローカルカウント: {localCount}</p>
      <p>🔴 グローバルカウント: {globalCount}</p>
      <button onClick={handleClick}>カウントアップ</button>
    </div>
  );
};

export default ChildComponent;

初期表示の時点で以下の画像のようになり、親コンポーネントからの呼び出し回数に依存して表示内容が変わっており、純関数ではないといえます。

※グローバル変数をuseEffectの実行回数で変化させています。
StrictModeでuseEffectは2回実行され、親コンポーネントでは3回コンポーネントを呼び出しています。

グローバルカウントの数の決まり方としては以下のようになります。

  1. 3回のコンポーネントの呼び出しに対して1回目のuseEffectでグローバル変数を操作してこの時点で3となる
  2. 1回目に呼び出されたコンポーネントに対して2回目のuseEffectが実行されたので、1番目のコンポーネントのグローバルカウントは4になる
  3. 2回目に呼び出されたコンポーネントに対して2回目のuseEffectが実行されたので、2番目のコンポーネントのグローバルカウントは5になる
  4. 3回目に呼び出されたコンポーネントに対して2回目のuseEffectが実行されたので、3番目のコンポーネントのグローバルカウントは6になる

Discussion