ReactのStrictModeで、何が2回呼ばれるのか

​​React​TypeScript,useEffect,useMemo,useReducer,useState

<StrictMode> で包むと、開発中だけ、コンポーネントの本体や useState に渡した関数などが2回呼ばれます。検証では、イベントハンドラは1回だけで、エフェクトが余分に動くのはマウントのときだけでした。本番用のビルドでは、どれも1回でした。何が2回呼ばれるかを知っておくと、ログが2行出ても慌てずに済み、2回呼ばれることで表に出るバグにも気づけます。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、jsdom 29.1.1 です。.tsx を tsc で型チェックしてから esbuild で変換し、jsdom の上で動かしました。ビルドは、本番用と書いた節を除いて開発用です。画像は、Chromium 141 を Playwright 1.56.1 で操作して作りました。

呼ばれた関数を順に記録するコンポーネント

StrictMode の公式リファレンスによると、StrictMode は開発中だけ、次のことをします。

  • コンポーネントを1回余分に描画する
  • エフェクトを1回余分に動かす
  • ref のコールバックを1回余分に動かす
  • 非推奨の API を使っていないか調べる

どの関数が何回呼ばれるかを確かめるため、呼ばれるたびに1行を記録するコンポーネントを作りました。log は、文字列を配列にためるだけの検証用の関数です。

tsx
import { useEffect, useMemo, useReducer, useState } from 'react';
import { log } from './log';

function reducer(total: number, action: { type: 'add' }): number {
  log('reducer');
  return action.type === 'add' ? total + 1 : total;
}

// ref のコールバック(描画のたびに作り直さないよう、コンポーネントの外に置く)
function paragraphRef(el: HTMLParagraphElement) {
  log('ref の準備');
  return () => log('ref の片付け');
}

export function Probe() {
  log('コンポーネントの本体');
  const [count, setCount] = useState(() => {
    log('useState の初期化関数');
    return 0;
  });
  const [total, dispatch] = useReducer(reducer, 0, (initial) => {
    log('useReducer の初期化関数');
    return initial;
  });
  const doubled = useMemo(() => {
    log('useMemo の計算');
    return count * 2;
  }, [count]);
  useEffect(() => {
    log(`エフェクトの準備 count=${count}`);
    return () => log(`エフェクトの片付け count=${count}`);
  }, [count]);

  function handleClick() {
    log('クリックのイベントハンドラ');
    setCount((c) => {
      log('setCount の更新関数');
      return c + 1;
    });
    dispatch({ type: 'add' });
  }

  return (
    <div>
      <p ref={paragraphRef}>count={count} doubled={doubled} total={total}</p>
      <button onClick={handleClick}>+1</button>
    </div>
  );
}

このコンポーネントを、StrictMode で包まずに描画した場合と、包んで描画した場合とで比べました。どちらも、最初に描画してからボタンを1回クリックし、最後にアンマウントしています。

最初の描画で2回呼ばれたもの

StrictMode なしでは、最初の描画の記録は次のとおりでした。どの関数も1回ずつです。

text
[最初の描画]
  コンポーネントの本体
  useState の初期化関数
  useReducer の初期化関数
  useMemo の計算
  ref の準備
  エフェクトの準備 count=0

StrictMode で包むと、次のようになりました。

text
[最初の描画]
  コンポーネントの本体
  useState の初期化関数
  useState の初期化関数
  useReducer の初期化関数
  useReducer の初期化関数
  useMemo の計算
  useMemo の計算
  コンポーネントの本体
  ref の準備
  エフェクトの準備 count=0
  ref の片付け
  エフェクトの片付け count=0
  ref の準備
  エフェクトの準備 count=0

コンポーネントの本体が2回呼ばれました。useState と useReducer の初期化関数、useMemo の計算も2回ずつです。この3つは、1回目の本体の記録の後に2回ずつ続けて並び、2回目の本体の後には出ませんでした。

React 19 のアップグレードガイドにある StrictMode の変更点には、2回目の描画では useMemo と useCallback が1回目の結果を使い回すと書かれています。useMemo の記録が2回目の本体の後に出なかったのは、この説明と合っています。

エフェクトと ref のコールバックは、準備の後に片付けが1回入り、もう一度準備が呼ばれました。マウントしただけで、アンマウントはしていません。useEffect のリファレンスの注意事項によると、これは本来の準備の前に、開発時だけ準備と片付けを1回余分に行う動きです。片付けが準備を正しく元に戻しているかを試すためのものです。ref のコールバックについても、StrictMode のリファレンスにある、ref のコールバックをもう一度動かす理由の説明に、同じように準備と片付けを1回余分に行うと書かれています。

StrictModeなしでは本体、refの準備、エフェクトの準備の3つ。StrictModeありでは本体が2回、refとエフェクトの準備、片付け、もう一度準備の8つが並ぶ
最初の描画で呼ばれた順番(初期化関数と useMemo の計算は省略)。StrictMode ありでは、マウントしただけで片付けが呼ばれる

クリックしたときに2回呼ばれたもの

ボタンを1回クリックしたときの記録です。StrictMode なしでは、次のとおりでした。

text
[ボタンを1回クリック]
  クリックのイベントハンドラ
  setCount の更新関数
  コンポーネントの本体
  reducer
  useMemo の計算
  エフェクトの片付け count=0
  エフェクトの準備 count=1
  表示: count=1 doubled=2 total=1

StrictMode ありでは、次のようになりました。

text
[ボタンを1回クリック]
  クリックのイベントハンドラ
  setCount の更新関数
  コンポーネントの本体
  setCount の更新関数
  reducer
  reducer
  useMemo の計算
  useMemo の計算
  コンポーネントの本体
  エフェクトの片付け count=0
  エフェクトの準備 count=1
  表示: count=1 doubled=2 total=1

setCount に渡した更新関数、reducer、useMemo の計算、コンポーネントの本体が2回ずつ呼ばれました。useMemo は、依存配列の count が変わったので計算し直しています。

一方で、イベントハンドラの handleClick は1回だけでした。useState のリファレンスにある、初期化関数や更新関数が2回呼ばれるときの説明にも、純粋である必要があるのはコンポーネントと初期化関数、更新関数だけで、イベントハンドラは2回呼ばれないと書かれています。

エフェクトも、片付けと準備が1回ずつで、StrictMode なしと同じでした。余分な準備と片付けが入ったのは、マウントのときだけです。表示はどちらも count=1 doubled=2 total=1 で、2回呼ばれても結果は変わりませんでした。アンマウントの記録も、StrictMode の有無で同じでした。

2回呼ばれることで見つかるバグ

公式リファレンスの説明では、2回呼ぶのは、純粋でない関数や片付けの漏れに気づかせるためです(描画を2回行う理由、エフェクトをもう一度動かす理由)。この2種類のバグを、わざと書いて確かめました。

描画の中で、props の配列を書き換える

リファレンスにある例と同じく、props で受け取った配列の最後に項目を足すコンポーネントです。items は cats と同じ配列なので、push すると親から渡された配列そのものが変わります。

tsx
// 描画の中で、props で受け取った配列を書き換えてしまう(純粋でない)
export function CatList({ cats }: { cats: string[] }) {
  const items = cats;
  items.push('+ 猫を追加');
  return (
    <ul>
      {items.map((name, i) => (
        <li key={i}>{name}</li>
      ))}
    </ul>
  );
}
text
StrictMode なし: 表示=たま / みけ / + 猫を追加 元の配列の長さ=3
StrictMode あり: 表示=たま / みけ / + 猫を追加 / + 猫を追加 元の配列の長さ=4

StrictMode なしでは、見た目は正しく表示されます。それでも元の配列は2つから3つに増えていて、バグは隠れたままです。StrictMode ありでは、本体が2回呼ばれるので「+ 猫を追加」が2つ並び、最初の描画ですぐに気づけます。Chromium で開いても、同じ表示でした。

StrictModeなしでは「+ 猫を追加」が1つだけ表示され、StrictModeありでは2つ並んで表示されている
同じ CatList を StrictMode なし(上)とあり(下)で描画した画面

直すには、元の配列をコピーしてから足します。StrictMode ありでも「たま / みけ / + 猫を追加」と表示され、元の配列の長さは2のままでした。

tsx
const items = [...cats, '+ 猫を追加'];

エフェクトの片付けを書き忘れる

検証用の、にせのチャット接続を作りました。connect() を呼ぶと接続の数が1つ増え、返すオブジェクトの close() で1つ減ります。片付けを書き忘れたエフェクトと、書いたエフェクトを比べました。

tsx
// 片付けを書き忘れたエフェクト
export function ChatNoCleanup() {
  useEffect(() => {
    connect();
  }, []);
  return <p>チャット</p>;
}

// 片付けを書いたエフェクト
export function ChatWithCleanup() {
  useEffect(() => {
    const conn = connect();
    return () => conn.close();
  }, []);
  return <p>チャット</p>;
}
text
片付けなし / StrictMode なし: マウント後の接続数=1 アンマウント後の接続数=1
片付けなし / StrictMode あり: マウント後の接続数=2 アンマウント後の接続数=2
片付けあり / StrictMode なし: マウント後の接続数=1 アンマウント後の接続数=0
片付けあり / StrictMode あり: マウント後の接続数=1 アンマウント後の接続数=0

片付けがないと、StrictMode ありではマウントしただけで接続が2つになりました。StrictMode なしでもアンマウントの後に接続が1つ残っていて、漏れがあること自体は同じです。StrictMode は、それをマウントの時点で見える形にしてくれます。片付けを書けば、StrictMode ありでも接続は1つで、アンマウントの後は0になりました。

ポイント

StrictMode で何かが2回動いて困ったときは、まず片付けの書き忘れや、描画の中での書き換えを疑います。リファレンスには、配列をコピーしてから足す、片付けを書く、といった直し方が載っています。検証でも、そう直すと、StrictMode ありで「+ 猫を追加」は1つ、マウント後の接続は1つになりました。

アプリの一部だけを StrictMode で包んだとき

StrictMode は、アプリの一部だけを包むこともできます(公式リファレンスにある、アプリの一部だけで有効にする例)。ルートを包んだ場合、<div> の中の一部だけを包んだ場合、StrictMode なしの場合とで、エフェクトの準備と Content の本体が何回呼ばれるかを数えました。Content は本体の回数を数えるだけのコンポーネントで、EffectCounter はエフェクトの準備のたびに onSetup を呼ぶコンポーネントです。f は、呼ばれた回数を数える関数です。一部だけ包んだときに描画した要素は、次のとおりです。

tsx
<div>
  <Header />
  <StrictMode>
    <Content />
    <EffectCounter onSetup={f} />
  </StrictMode>
</div>
text
ルートを StrictMode で包む: エフェクトの準備=2回 Content の本体=2回
一部だけ StrictMode で包む: エフェクトの準備=1回 Content の本体=2回
  (StrictMode の外の Header の描画=1回)
StrictMode なし: エフェクトの準備=1回 Content の本体=1回

一部だけ包んだ場合、中の Content の本体は2回呼ばれましたが、最初の描画でのエフェクトの準備は1回でした。外の Header は1回だけです。

リファレンスの注意書きによると、ルートを包んでいないときは、本番でも起こりうる動きだけが有効になります。親のエフェクトは動かさずに子のエフェクトだけを2回動かすことは本番では起きないため、最初のマウントでエフェクトを余分に動かさない、という説明です。上の結果で、一部だけ包んだときのマウント時の準備が1回だったのは、このためです。マウントの時点で片付けの漏れに気づきたいなら、ルートを包みます。

console.log は2回出る

コンポーネントの本体で console.log を呼ぶと、StrictMode ありでは2回出ました。

tsx
// 描画のたびに console.log を呼ぶ
export function Hello() {
  console.log('Hello を描画');
  return <p>こんにちは</p>;
}
text
--- StrictMode なし ---
Hello を描画
--- StrictMode あり ---
Hello を描画
Hello を描画

これは jsdom(Node.js)で、React DevTools を入れずに動かした結果です。リファレンスの補足によると、React DevTools を入れたブラウザでは、2回目の描画で出したログは少し薄く表示されます。DevTools には、それを出さないようにする設定(初期状態はオフ)もあると書かれています。この点は、ブラウザでは試していません。

本番用のビルドでは、どれも1回

同じ Probe を StrictMode で包み、本番用のビルド(NODE_ENV=production)で動かしました。本番用のビルドの React には act が入っていなかったので、ここでは flushSync で描画し、少し待ってから記録を読んでいます。

text
--- StrictMode あり(NODE_ENV=production) ---
[最初の描画]
  コンポーネントの本体
  useState の初期化関数
  useReducer の初期化関数
  useMemo の計算
  ref の準備
  エフェクトの準備 count=0
[ボタンを1回クリック]
  クリックのイベントハンドラ
  setCount の更新関数
  コンポーネントの本体
  reducer
  useMemo の計算
  エフェクトの片付け count=0
  エフェクトの準備 count=1
  表示: count=1 doubled=2 total=1

StrictMode で包んでいても、どれも1回ずつで、開発用のビルドで StrictMode なしにしたときと同じ並びでした。公式リファレンスにある、アプリ全体で有効にする方法の説明にも、StrictMode のチェックは開発中だけで、本番用のビルドには影響しないと書かれています。

2回呼ばれたもの、呼ばれなかったものの一覧

検証の結果をまとめます。回数は、開発用のビルドで、ルートを StrictMode で包んだときのものです。公式リファレンスの一覧には、クラスコンポーネントの constructor、render、shouldComponentUpdate なども、2回呼ばれるものとして載っています。クラスコンポーネントはこの記事では試していないので、表には入れていません。

関数 最初の描画 クリック1回
コンポーネントの本体 2回 2回
useState の初期化関数 2回 ―
setCount に渡した更新関数 ― 2回
useReducer の初期化関数 2回 ―
reducer ― 2回
useMemo の計算 2回 2回(依存配列が変わったとき)
エフェクト 準備→片付け→準備 片付け→準備(StrictMode なしと同じ)
ref のコールバック 準備→片付け→準備 呼ばれない(関数をコンポーネントの外に置いた場合)
イベントハンドラ ― 1回

本番用のビルドではどれも余分に呼ばれないので、開発中に関数が2回呼ばれたり、エフェクトが余分に動いたりすること自体は不具合ではありません。2回呼ばれたことで表示や接続の数が変わるときは、描画の中で props の配列などを書き換えていないか、エフェクトの片付けを書き忘れていないかを見直します。

​Posted by 86