StrictModeとは、開発ビルドのときだけコンポーネントの関数やEffectをわざと余分に1回動かし、クリーンアップ漏れや副作用の混入を早めに見つけるためのReactの仕組みです。 useEffectが2回動くのは不具合ではなく、「セットアップ → クリーンアップ → セットアップ」という1往復を追加して、コンポーネントが付け外しされても正しく動くかを確かめているためです。本番ビルドでは1回しか動きません。

この記事では、React 19.3.0 と 18.3.1 をブラウザで実際に動かし、開発ビルドと本番ビルドでログがどう変わるかを並べて示します。そのうえで、fetch・イベント購読・タイマー・WebSocket・解析タグ送信の場面ごとに、クリーンアップで直すコードと直した後の実行結果を載せています。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / React 19.3.0・react-dom 19.3.0(比較用に 18.3.1)/ esbuild 0.28.2 でビルド / Playwright 1.56.0 の Chromium 141 で実行。

StrictModeの早見表

まず、開発ビルド+StrictModeで何が余分に動くかを表にまとめます。「実測」の列は、後述のコードを React 19.3.0 で動かして数えた回数です。

対象 開発+StrictMode(実測) 本番ビルド(実測) 補足
コンポーネント本体 2回 1回 最上位の処理だけ。イベントハンドラの中は対象外
useState の初期化関数 2回 1回 useState(() => ...) の関数
useMemo の計算関数 2回 1回 React 19 は2回目のレンダーで結果を再利用
setState に渡す更新関数 2回 1回 setN(p => p + 1) の関数
useEffect セットアップ → クリーンアップ → セットアップ セットアップのみ マウント時に1往復を追加
useLayoutEffect セットアップ → クリーンアップ → セットアップ セットアップのみ useEffect と同じ扱い
ref コールバック 要素 → クリーンアップ(または null)→ 要素 要素で1回 React 19 から。18.3.1 では1回
イベントハンドラ 1回 1回 クリック1回につき1回

やりたいこと別の直し方は次のとおりです。

場面 2回動くと起きること 直し方
fetch でデータ取得 リクエストが2件、setState も2回 AbortController か ignore フラグをクリーンアップで使う
addEventListener 1回のイベントで処理が2回走る クリーンアップで removeEventListener
setInterval カウントが2倍の速さで進む クリーンアップで clearInterval
WebSocket 接続が2本残る クリーンアップで close()
解析タグ送信 開発時だけ2件送られる そのままでよい(本番は1件)
アプリ起動時に1回だけの処理 2回走る コンポーネントの外に置く

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React StrictModeとは|useEffectが2回動く理由」

StrictModeとは何をする仕組みか

StrictModeは、ツリーを StrictMode コンポーネントで包むと有効になります。画面には何も描画せず、包んだ範囲のコンポーネントに対して開発時だけ追加の点検を行います。

import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>
);

公式ドキュメント(react.dev の StrictMode リファレンス)では、StrictModeが開発時に行うことを次の4つにまとめています。

点検 内容 見つけやすくなるもの
余分な再レンダー コンポーネント本体などを2回呼ぶ レンダー中に外の変数を書き換えるなど、純粋でない処理
Effect の余分な再実行 セットアップとクリーンアップを1往復追加する クリーンアップの書き忘れ
ref コールバックの余分な再実行 ref コールバックでも1往復追加する ref のクリーンアップの書き忘れ
非推奨 API の警告 UNSAFE_ で始まるクラスのライフサイクルなどを警告する 将来の版で使えなくなる書き方

同じページには「これらの点検はすべて開発時だけで、本番ビルドには影響しない」と明記されています。また、2回呼ばれる関数として、コンポーネント本体、useState・set 関数・useMemo・useReducer に渡す関数、クラスコンポーネントの constructor・render・shouldComponentUpdate などが挙げられています。

なぜ2回動かすのか

Reactのコンポーネントは「同じ入力なら同じ結果を返す」ことが前提です。レンダー中に外の配列へ push するような処理が紛れ込むと、1回だけ動かしたときは気づきにくく、2回動かすと結果がずれてすぐにわかります。

Effectも同じ考え方です。画面を切り替えて戻ってきたときなど、コンポーネントは付け外しされることがあります。 クリーンアップがないと、戻るたびに購読やタイマーが増えていきます。StrictModeはこの付け外しをマウント直後に1回だけ再現してくれるので、ページを行き来して確かめる前に気づけます。

sequenceDiagram
    participant R as React
    participant E as useEffect
    Note over R,E: 開発ビルド+StrictMode
    R->>E: セットアップ(1回目)
    R->>E: クリーンアップ
    R->>E: セットアップ(2回目)
    Note over R,E: 本番ビルド
    R->>E: セットアップ(1回だけ)

console.logの出力順を開発と本番で比べる

次のコンポーネントで、どの順番で何回呼ばれるかを記録しました。番号はコードに書いた順です。ログは配列にためて、Playwright から読み出しています(React DevTools を入れたブラウザでは、2回目のレンダー中の console.log が薄い色で表示されると公式ドキュメントにあります)。

// app.jsx
import { StrictMode, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { createRoot } from 'react-dom/client';

window.__log = [];
const log = (m) => { window.__log.push(m); };

function Order() {
  log('1 本体');
  const [n] = useState(() => { log('2 useState初期化関数'); return 0; });
  const v = useMemo(() => { log('3 useMemo'); return n * 2; }, [n]);
  useLayoutEffect(() => {
    log('5 useLayoutEffect セットアップ');
    return () => log('5 useLayoutEffect クリーンアップ');
  }, []);
  useEffect(() => {
    log('6 useEffect セットアップ');
    return () => log('6 useEffect クリーンアップ');
  }, []);
  return (
    <p ref={(el) => {
      log('4 refコールバック ' + (el ? 'p要素' : 'null'));
      return () => log('4 refクリーンアップ');
    }}>{v}</p>
  );
}

createRoot(document.getElementById('root')).render(
  <StrictMode><Order /></StrictMode>
);

開発ビルドと本番ビルドは、esbuild で process.env.NODE_ENV を切り替えて作りました。

npx esbuild app.jsx --bundle --jsx=automatic \
  --define:process.env.NODE_ENV='"development"' --outfile=dev.js
npx esbuild app.jsx --bundle --jsx=automatic --minify \
  --define:process.env.NODE_ENV='"production"' --outfile=prod.js

開発ビルド+StrictMode(React 19.3.0)

1 本体
2 useState初期化関数
2 useState初期化関数
3 useMemo
3 useMemo
1 本体
4 refコールバック p要素
5 useLayoutEffect セットアップ
6 useEffect セットアップ
5 useLayoutEffect クリーンアップ
4 refクリーンアップ
6 useEffect クリーンアップ
4 refコールバック p要素
5 useLayoutEffect セットアップ
6 useEffect セットアップ

本番ビルド+StrictMode(React 19.3.0)

1 本体
2 useState初期化関数
3 useMemo
4 refコールバック p要素
5 useLayoutEffect セットアップ
6 useEffect セットアップ

同じコード・同じ StrictMode でも、本番ビルドでは6行、開発ビルドでは15行になります。 開発ビルドで StrictMode を外した場合も、本番と同じ6行でした。つまり「2回動く」のは、開発ビルドかつ StrictMode の内側という2つの条件がそろったときだけです。

ログから読み取れることを整理します。

ログの部分 起きていること
1〜6行目 レンダーを2回。初期化関数と useMemo は1回目のレンダーの中で続けて2回呼ばれ、2回目の本体では呼ばれていない
7〜9行目 通常のマウント。ref → useLayoutEffect → useEffect の順
10〜12行目 StrictMode が追加したクリーンアップ
13〜15行目 StrictMode が追加した2回目のセットアップ

更新関数とイベントハンドラ

ボタンを1回クリックしたときのログも取りました。

function Updater() {
  const [n, setN] = useState(0);
  log('本体 n=' + n);
  return (
    <button onClick={() => {
      log('ハンドラ');
      setN((p) => { log('更新関数 p=' + p); return p + 1; });
    }}>{n}</button>
  );
}
(開発ビルド+StrictMode)      (本番ビルド)
本体 n=0                        本体 n=0
本体 n=0                        ハンドラ
ハンドラ                        更新関数 p=0
更新関数 p=0                    本体 n=1
更新関数 p=0
本体 n=1
本体 n=1

イベントハンドラは開発時も1回だけです。更新関数は2回呼ばれますが、どちらも p=0 を受け取って 1 を返すので、結果は n=1 で変わりません。更新関数の中で外の変数を書き換えていると、ここで差が出て気づけます。

useEffectが2回動くと困る場面と直し方

公式ドキュメント(react.dev「Synchronizing with Effects」)は、この話題について次のように述べています。考えるべきは「Effect を1回だけ動かす方法」ではなく、「再マウントされても正しく動くように Effect を直す方法」であり、たいていの答えはクリーンアップ関数を書くことだ、という内容です。

以下、場面ごとに「直す前 → 直した後」のコードと、開発ビルド+StrictMode(React 19.3.0)での結果を並べます。

fetchの二重送信:AbortControllerかignoreフラグ

直す前のコードです。サーバー側でリクエスト数を数え、setState の回数もログに出しました。

import { useEffect, useState } from 'react';

function FetchNaive() {
  const [u, setU] = useState(null);
  useEffect(() => {
    fetch('/api/user?id=1')
      .then((r) => r.json())
      .then((d) => { log('setState'); setU(d); });
  }, []);
  return <p>{u ? u.name : '...'}</p>;
}
(開発ビルド+StrictMode)   サーバーが受けたリクエスト: 2件 / setState: 2回
(本番ビルド)               サーバーが受けたリクエスト: 1件 / setState: 1回

直し方は2つあります。1つ目は AbortController で、不要になったリクエストを中断します。

function FetchAbort() {
  const [u, setU] = useState(null);
  useEffect(() => {
    const ac = new AbortController();
    fetch('/api/user?id=1', { signal: ac.signal })
      .then((r) => r.json())
      .then((d) => { log('setState'); setU(d); })
      .catch((e) => {
        if (e.name !== 'AbortError') throw e;
        log('catch ' + e.name);
      });
    return () => ac.abort();
  }, []);
  return <p>{u ? u.name : '...'}</p>;
}
catch AbortError
setState
サーバーが受けたリクエスト: 2件 / setState: 1回

2つ目は ignore フラグで、返ってきた結果を使わないようにします。

function FetchIgnore() {
  const [u, setU] = useState(null);
  useEffect(() => {
    let ignore = false;
    fetch('/api/user?id=1')
      .then((r) => r.json())
      .then((d) => {
        if (ignore) { log('結果を捨てた'); return; }
        log('setState');
        setU(d);
      });
    return () => { ignore = true; };
  }, []);
  return <p>{u ? u.name : '...'}</p>;
}
結果を捨てた
setState
サーバーが受けたリクエスト: 2件 / setState: 1回

どちらの方法でも、開発時にサーバーへ届くリクエストは2件のままです。直したのは「不要になった1件目の結果が画面に影響しない」ことです。公式ドキュメントも、すでに行われたネットワークリクエストを取り消すことはできないが、関係なくなった fetch がアプリに影響し続けないようにするのがクリーンアップの役目だ、と説明しています。本番ビルドでは1件しか送られません。

方法 1件目のリクエスト 向いている場面
AbortController ブラウザ側で中断される(AbortError が catch に届く) 通信そのものを止めたいとき。AbortError を除外する分岐が必要
ignore フラグ 最後まで受信し、結果だけ使わない 手軽に書きたいとき。fetch 以外の Promise にも使える

この書き方は、StrictMode のためだけのものではありません。id が変わるたびに取得する Effect では、先に送ったリクエストの応答が後から届いて画面を上書きすることがあります。クリーンアップで前の結果を無効にしておけば、常に最後に送ったリクエストの結果だけが表示されます。

addEventListener:removeEventListenerを返す

// 直す前
function ListenNaive() {
  useEffect(() => {
    window.addEventListener('ping', () => log('ping受信'));
  }, []);
  return null;
}

// 直した後
function ListenFixed() {
  useEffect(() => {
    const h = () => log('ping受信');
    window.addEventListener('ping', h);
    return () => window.removeEventListener('ping', h);
  }, []);
  return null;
}

window.dispatchEvent(new Event('ping')) を1回実行したときのログです。

(直す前・開発ビルド+StrictMode)
ping受信
ping受信

(直した後・開発ビルド+StrictMode)
ping受信

直す前のコードは、本番ビルドでは ping受信 が1行だけになり、一見問題なく見えます。ただし後述のとおり、コンポーネントを付け外しするとリスナーが増えていきます。開発時に2行出たことが、その合図です。 removeEventListener には登録時と同じ関数を渡す必要があるので、ハンドラを変数に入れてから登録します。

setInterval:clearIntervalを返す

// 直す前
function TimerNaive() {
  const [c, setC] = useState(0);
  useEffect(() => {
    setInterval(() => setC((x) => x + 1), 100);
  }, []);
  return <p id="c">{c}</p>;
}

// 直した後
function TimerFixed() {
  const [c, setC] = useState(0);
  useEffect(() => {
    const id = setInterval(() => setC((x) => x + 1), 100);
    return () => clearInterval(id);
  }, []);
  return <p id="c">{c}</p>;
}

100ミリ秒ごとに1増えるカウンターが、1秒間でいくつ増えたかを測りました。

コード 開発ビルド+StrictMode 本番ビルド
直す前(TimerNaive) 20 10
直した後(TimerFixed) 10 10

直す前は、タイマーが2本動いているため2倍の速さで進みます。「開発環境だけカウントが速い」「ログが倍出る」という症状は、クリーンアップのない setInterval や setTimeout を探すと見つかります。

WebSocket:close()を返す

サーバー側で接続数を数えました。

// 直す前
function WsNaive() {
  useEffect(() => {
    const ws = new WebSocket('ws://' + location.host + '/ws');
    ws.onopen = () => log('open');
    ws.onclose = () => log('close');
  }, []);
  return null;
}

// 直した後
function WsFixed() {
  useEffect(() => {
    const ws = new WebSocket('ws://' + location.host + '/ws');
    ws.onopen = () => log('open');
    ws.onclose = () => log('close');
    return () => ws.close();
  }, []);
  return null;
}
コード ログ 接続要求の合計 開いたままの接続
直す前(開発+StrictMode) open / open 2 2
直した後(開発+StrictMode) close / open 2 1
直す前(本番ビルド) open 1 1

直した後は、1本目が閉じられて1本だけが残ります。このとき Chromium のコンソールには次の警告が1行出ました。

WebSocket connection to 'ws://127.0.0.1:8720/ws' failed: WebSocket is closed before the connection is established.

これは、1本目の接続が確立する前に close() を呼んだことを知らせるもので、2本目の接続は正常に開いています。開発時だけ出るこの警告を出さないようにしたい場合は、接続中なら開いてから閉じるようにします。

function WsFixed2() {
  useEffect(() => {
    const ws = new WebSocket('ws://' + location.host + '/ws');
    ws.onopen = () => log('open');
    ws.onclose = () => log('close');
    return () => {
      if (ws.readyState === WebSocket.CONNECTING) {
        ws.addEventListener('open', () => ws.close());
      } else {
        ws.close();
      }
    };
  }, []);
  return null;
}
open
close
open
接続要求の合計: 2 / 開いたままの接続: 1 / コンソールの警告: なし

解析タグ送信:基本はそのままでよい

ページ表示を記録する送信も、開発時は2件になります。

function Beacon() {
  useEffect(() => {
    navigator.sendBeacon('/beacon', 'page_view');
    log('送信');
  }, []);
  return null;
}
ビルド サーバーが受けた件数
開発ビルド+StrictMode 2
本番ビルド 1

公式ドキュメントは、この場合について「このコードはそのままにしておくことを勧める」としています。本番では1件しか送られないこと、開発中はファイルを保存するたびにコンポーネントが再マウントされるので、どのみち余分な記録が増えることが理由です。

どうしても開発時の件数も1件にしたい場合は、コンポーネントの外に変数を置く方法があります。

let sent = false;

function BeaconOnce() {
  useEffect(() => {
    if (sent) return;
    sent = true;
    navigator.sendBeacon('/beacon', 'page_view');
    log('送信');
  }, []);
  return null;
}

このコードでは、開発ビルド+StrictMode でも受信は1件でした。モジュールの変数はページを読み込んでいる間ずっと残るので、「ページ読み込みにつき1回」という意味になります。画面を切り替えるたびに送りたい記録には向きません。

Effectに書かないほうがよい処理

2回動いて困る処理の中には、そもそも Effect に置かないほうがよいものがあります。公式ドキュメントは次の2つを挙げています。

処理 置き場所 理由
アプリ起動時に1回だけ行う初期化 コンポーネントの外(モジュールの最上位) 表示とは関係なく、読み込み時に1回でよい
注文の確定など、ユーザーの操作で起きる送信 ボタンのイベントハンドラ 画面が表示されたからではなく、ボタンを押したから起きる処理

先ほどの実測のとおり、イベントハンドラは開発時も1回だけです。「表示されたら行う」のか「操作されたら行う」のかを分けて考えると、置き場所が決まります。

useRefフラグで隠す方法が勧められない理由

検索すると、次のように useRef で「実行済み」の印をつけて2回目を飛ばす方法が見つかります。

function TimerFlag() {
  const [c, setC] = useState(0);
  const started = useRef(false);
  useEffect(() => {
    if (started.current) { log('2回目はスキップ'); return; }
    started.current = true;
    const id = setInterval(() => setC((x) => x + 1), 100);
    return () => { log('clearInterval'); clearInterval(id); };
  }, []);
  return <p id="c">{c}</p>;
}

これを実際に動かすと、次の結果になりました。

(開発ビルド+StrictMode)
clearInterval
2回目はスキップ
1秒間の増分 = 0

(本番ビルド)
1秒間の増分 = 10

開発ビルドではタイマーが止まったままになります。 1回目のセットアップで始めたタイマーをクリーンアップが止め、2回目のセットアップはフラグで飛ばされるためです。useRef の値は StrictMode の再実行をまたいで残るので、「クリーンアップあり+フラグ」の組み合わせはこのように動かなくなります。

では、クリーンアップを書かずにフラグだけにするとどうでしょうか。

function ListenFlag() {
  const started = useRef(false);
  useEffect(() => {
    if (started.current) return;
    started.current = true;
    window.addEventListener('ping', () => log('ping受信'));
  }, []);
  return null;
}

function Toggle({ Child }) {
  const [on, setOn] = useState(true);
  return (
    <>
      <button onClick={() => setOn((v) => !v)}>toggle</button>
      {on && <Child />}
    </>
  );
}

Toggle のボタンを4回押して(外す → 付ける → 外す → 付ける)、そのあと ping を1回送ったときのログです。

子コンポーネント 開発ビルド+StrictMode 本番ビルド
ListenFlag(フラグのみ) ping受信 が3行 ping受信 が3行
ListenFixed(クリーンアップあり) ping受信 が1行 ping受信 が1行

マウント直後の2重実行は隠せますが、付け外しのたびにリスナーが1つずつ増える動きは、本番ビルドでもそのまま残っています。 フラグは StrictMode が知らせてくれた合図を見えなくするだけで、原因は直していません。公式ドキュメントも、ref で Effect の実行を防ぐ方法について「開発時に1回しか表示されなくなるが、バグは直らない。Effect は再マウント後も動く必要があり、そのためにはクリーンアップが必要」と注意しています。

同じ理由で、StrictMode そのものを外す対処も、合図が消えるだけです。まずクリーンアップを書き、それでも2回動いて困る処理は「Effect に置くべきか」を見直す、という順番が近道です。

対処 開発時の見た目 付け外ししたときの動き
クリーンアップを書く 2回動くが結果は1回分 正しく動く
useRef フラグ+クリーンアップ 1回に見える StrictMode では止まったままになる
useRef フラグのみ 1回に見える 購読やタイマーが増えていく
StrictMode を外す 1回に見える 購読やタイマーが増えていく

React 19とReact 18の違い

同じコード(ref コールバックがクリーンアップ関数を返さない版)を、React 18.3.1 と 19.3.0 の開発ビルド+StrictMode で動かしました。

(React 18.3.1)                    (React 19.3.0)
1 本体                              1 本体
2 useState初期化関数                2 useState初期化関数
3 useMemo                           2 useState初期化関数
1 本体                              3 useMemo
2 useState初期化関数                3 useMemo
3 useMemo                           1 本体
4 refコールバック p要素             4 refコールバック p要素
5 useLayoutEffect セットアップ      5 useLayoutEffect セットアップ
6 useEffect セットアップ            6 useEffect セットアップ
5 useLayoutEffect クリーンアップ    5 useLayoutEffect クリーンアップ
6 useEffect クリーンアップ          4 refコールバック null
5 useLayoutEffect セットアップ      6 useEffect クリーンアップ
6 useEffect セットアップ            4 refコールバック p要素
                                    5 useLayoutEffect セットアップ
                                    6 useEffect セットアップ
項目 React 18.3.1(実測) React 19.3.0(実測)
初期化関数・useMemo の呼ばれ方 レンダー1回目と2回目でそれぞれ1回ずつ 1回目のレンダー中に続けて2回。2回目のレンダーでは呼ばれない
ref コールバック マウント時に1回 要素 → null → 要素 の3回
ref コールバックが関数を返す 警告が出る クリーンアップ関数として扱われる
Effect の1往復 あり あり

React 19 のアップグレードガイド(react.dev)には、StrictMode の変更として次の2点が書かれています。

  • 開発時の二重レンダーで、useMemo と useCallback は1回目のレンダーの結果を2回目のレンダーで再利用する。すでに StrictMode に対応しているコンポーネントでは違いに気づかないはず、とされています。
  • 開発時、StrictMode は初回マウントで ref コールバックを二重に呼ぶ。マウント済みのコンポーネントが Suspense のフォールバックに置き換わる場面を再現するためです。

ref コールバックからクリーンアップ関数を返す書き方は React 19 で加わったものです。React 18.3.1 で関数を返すと、開発ビルドで次の警告が出ました。

Warning: Unexpected return value from a callback ref in p. A callback ref should not return a function.

React 19 では、クリーンアップ関数を返すと null での呼び出しの代わりにその関数が呼ばれます(1つ目のログの 4 refクリーンアップ)。ref コールバックの中で addEventListener や ResizeObserver を使うときは、Effect と同じようにクリーンアップを返しておくと、StrictMode の点検をそのまま通ります。

なお、アップグレードガイドには、ref コールバックで ref={current => (instance = current)} のように値を暗黙に返す書き方は TypeScript で受け付けられなくなるため、ref={current => { instance = current }} と波かっこで囲むように、という案内もあります。

2回動いて困ったときのチェックリスト

確認すること 見るところ 対応
本当に StrictMode が理由か 本番ビルドで1回になるか、StrictMode を一時的に外して1回になるか 1回になれば StrictMode の点検によるもの
Effect にクリーンアップがあるか useEffect の return 始めたことを止める・戻す処理を返す
クリーンアップが対になっているか add と remove、set と clear、open と close 登録時と同じ関数・同じ ID を使う
取得結果の扱い fetch の then の中 ignore フラグか AbortController
Effect に置くべき処理か 表示がきっかけか、操作がきっかけか 操作がきっかけならイベントハンドラへ
レンダー中に副作用がないか コンポーネント本体、初期化関数、useMemo、更新関数 外の変数の書き換えや送信を Effect かハンドラへ移す

本番でも2回動く場合は、StrictMode 以外の理由があります。依存配列の値がレンダーのたびに変わっている、同じコンポーネントを2か所で描画している、といった点を確認します。

よくある質問

Q. StrictModeは本番ビルドに影響しますか? A. 影響しません。今回の実測でも、同じコードを本番ビルドにすると各関数と Effect は1回ずつしか動きませんでした。公式ドキュメントにも、点検はすべて開発時だけと書かれています。StrictMode を付けたまま公開して問題ありません。

Q. 開発時にAPIが2回呼ばれるのを止めたいのですが。 A. クリーンアップ(AbortController か ignore フラグ)を書いたうえで、2件送られること自体はそのままにするのが基本です。画面に反映されるのは1回分になり、本番では1件しか送られません。取得の重複そのものを減らしたい場合は、結果をキャッシュするデータ取得ライブラリやフレームワークの取得機能を使う方法があります。

Q. 一部のコンポーネントだけStrictModeにできますか? A. できます。公式ドキュメントに「アプリの一部に対して有効にする」方法が載っており、対応が済んだ画面から順に包んでいく使い方ができます。同じページには、StrictMode で包んだツリーの内側で点検を外す方法はないこと、ルートではなく一部だけを包んだ場合は、初回マウント時の Effect の追加の再実行は行われないこと(親の Effect を伴わずに子の Effect だけが2回動く状況は本番で起こりえないため)も書かれています。Effect の点検まで受けたいときは、ルートで包みます。

Q. useLayoutEffectやrefコールバックも2回動きますか? A. はい。今回の実測では、useLayoutEffect は useEffect と同じくセットアップ → クリーンアップ → セットアップの順に動きました。ref コールバックは React 19.3.0 で 要素 → クリーンアップ → 要素 の順に呼ばれ、18.3.1 では1回でした。

まとめ

  • StrictModeは、開発ビルドのときだけ関数やEffectを余分に動かして、問題を早めに見つける仕組みです。本番ビルドでは1回しか動きません。
  • useEffectが2回動くのは、セットアップ → クリーンアップ → セットアップの1往復を追加しているためです。付け外しされても正しく動くかを確かめています。
  • 2回呼ばれるのは、コンポーネント本体、useState の初期化関数、useMemo、更新関数、Effect、ref コールバック(React 19〜)です。イベントハンドラは1回です。
  • 直し方の基本はクリーンアップを書くことです。fetch は AbortController か ignore フラグ、購読は removeEventListener、タイマーは clearInterval、WebSocket は close() を返します。
  • useRef フラグで2回目を飛ばす方法は、クリーンアップと組み合わせると開発時に止まったままになり、フラグだけでは付け外しで購読が増える動きが本番にも残ります。
  • 解析タグ送信はそのままでよく、起動時の初期化はコンポーネントの外、操作がきっかけの送信はイベントハンドラに置きます。
  • React 19 では、useMemo の結果が2回目のレンダーで再利用され、ref コールバックも二重に呼ばれるようになりました。

参考・出典

  • React 公式ドキュメント「StrictMode」 https://react.dev/reference/react/StrictMode
  • React 公式ドキュメント「Synchronizing with Effects」 https://react.dev/learn/synchronizing-with-effects
  • React 公式ブログ「React 19 Upgrade Guide」 https://react.dev/blog/2024/04/25/react-19-upgrade-guide

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「React StrictModeとは|useEffectが2回動く理由」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?