暇 ひまネタNEWS(^o^) | スキマ時間にちょっとしたネタを提供スキマ時間に、話題のまとめを一気読み 全サイト自動巡回中
HIMA NETA ORIGINAL READER
本文だけモード 約13分で読めます

記事を読む

読みやすさ この端末に設定を保存します
書体
文字
背景

広告・ランキング・関連記事を除き、配信元の安全な文字装飾だけを残しています。

4

いいねしたユーザー一覧へ移動

0

X(Twitter)でシェアする

Facebookでシェアする

はてなブックマークに追加する

一度削除した記事は復旧できません。

この記事の編集中の下書きも削除されます。

削除してよろしいですか?

「初回マウント時は実行せず、依存配列が変わったときだけ実行したい」という場面で、useUpdateEffect のようなカスタムフックを自作することがあります。

ネットでよく見かける実装は「1つの useEffect の中で ref を立てる」形ですが、この実装は React の StrictMode 下で壊れます。

この記事では、

  • なぜ壊れるのか(StrictMode の挙動)
  • 2つの effect に分けて直す方法と、その仕組み
  • React 19.2 の <Activity> と組み合わせたときの落とし穴

を、実際に動かしたログ付きで説明します。

対象読者

  • React でカスタムフックを書いている人
  • StrictMode で effect が2回走る理由をなんとなくしか理解していない人

動作確認環境

  • React / React DOM 19.2.8

よくある実装

import { useEffect, useRef } from 'react'
import type { DependencyList, EffectCallback } from 'react'

export function useUpdateEffect(effect: EffectCallback, deps?: DependencyList) {
  const isMountedRef = useRef(false)

  useEffect(() => {
    if (!isMountedRef.current) {
      isMountedRef.current = true // 初回はフラグを立てるだけ
      return
    }
    return effect()
  }, deps)
}

一見正しく見えますが、StrictMode で動かすと初回マウント時に effect が実行されてしまいます。

実行ログ

v=1 でマウントし、その後 v=2 に更新したときのログです。

[mount v=1] run(1)              ← 初回なのに実行されている
[update v=2] cleanup(1) run(2)

原因:StrictMode の「疑似再マウント」

開発環境の StrictMode では、React はマウント直後にアンマウント → 再マウントを1回シミュレートします。effect が正しくクリーンアップを書けているか検査するための仕組みです。

ここで重要なのは次の点です。

  • effect は「実行 → クリーンアップ → 再実行」と2回走る
  • コンポーネントは実際には破棄されないので、ref の値はそのまま残る

これを先ほどの実装に当てはめると、以下のようになります。

段階 effect の動き ref
1回目のマウント false なのでフラグを立ててスキップ ✅ true
疑似アンマウント (クリーンアップなし) true のまま
再マウント true なので effect 実行 ❌ true

「1回目でフラグを立てた」という事実が ref に残ったまま2回目が来るため、2回目を「更新」と誤認してしまいます。

「クリーンアップでフラグを戻せばいい」も NG

では effect のクリーンアップで ref を戻せばいいかというと、これもうまくいきません。

useEffect(() => {
  // ...
  return () => {
    isMountedRef.current = false // ❌
  }
}, deps)

この effect のクリーンアップは、アンマウント時だけでなく deps が変わるたびにも走ります。更新のたびにフラグが戻り、毎回「初回扱い」でスキップされてしまいます。

つまり「アンマウント時だけ走るクリーンアップ」が必要で、それは依存配列が [] の effect でしか書けません。

2つの effect に分ける

import { useEffect, useRef } from 'react'
import type { DependencyList, EffectCallback } from 'react'

/**
 * 初回マウント時はスキップし、deps 変更時のみ effect を実行する。
 * - deps 省略時: 初回以外の毎レンダーで実行
 * - deps = []: 一度も実行されない
 */
export function useUpdateEffect(effect: EffectCallback, deps?: DependencyList) {
  const isFirstMountRef = useRef(true)

  // ① 本体。⚠️ 必ず②より前に宣言すること
  useEffect(() => {
    if (isFirstMountRef.current) return
    return effect()
    // eslint-disable-next-line react-hooks/exhaustive-deps -- deps は呼び出し側で検査する
  }, deps)

  // ② マウント追跡。依存配列 [] なのでクリーンアップはアンマウント時のみ
  useEffect(() => {
    isFirstMountRef.current = false
    return () => {
      isFirstMountRef.current = true // 「次は初回扱い」に戻す
    }
  }, [])
}

ポイント1:宣言順

同じコンポーネント内の effect は宣言順に実行されます。初回マウント時は

  1. ①が true を見てスキップ
  2. ②が false に切り替える

となります。①と②を入れ替えると、②が先に false にしてしまい、①が初回から実行されるので注意してください。

ポイント2:②のクリーンアップで true に戻す

ここがこの記事の本題です。

段階 ①の動き ②の動き ref
1回目のマウント true なのでスキップ ✅ false にする false
疑似アンマウント (クリーンアップなし) true に戻す true
再マウント true なのでスキップ ✅ false にする false

「アンマウントされたなら、次は初回扱い」とリセットしているだけです。②は依存配列が [] なので、このクリーンアップは deps 変更では走りません。

ちなみに②のクリーンアップを書かない場合は、よくある実装と同じく初回で実行されてしまいます。

[mount v=1] run(1)              ← クリーンアップなし版:NG
[update v=2] cleanup(1) run(2)

修正版のログは以下のとおりです。

[mount v=1]                     ← スキップされる ✅
[update v=2] run(2)

ESLint の設定

フック内部では deps が配列リテラルではないため、react-hooks/exhaustive-deps が警告を出します。上のコードでは disable コメントで抑制しています。

その代わり、呼び出し側の依存配列はチェックされるように additionalHooks を設定しておきましょう。

{
  "rules": {
    "react-hooks/exhaustive-deps": ["warn", { "additionalHooks": "(useUpdateEffect)" }]
  }
}

補足:<Activity> と組み合わせると別の落とし穴がある

React 19.2 で追加された <Activity> は、mode="hidden" にするとstate や ref を保持したまま effect をクリーンアップし、visible に戻すと effect を再実行します。つまり StrictMode の疑似再マウントと同じことが、本番環境でも起きます。

通常の useEffect と、上の useUpdateEffect を比べてみます。

// 通常の useEffect
[mount v=1] run(1) [update v=2] cleanup(1) run(2)
[hide] cleanup(2) [show] run(2)
[hide] cleanup(2) [v=3 while hidden] [show] run(3)

// useUpdateEffect(2 effect 版)
[mount v=1] [update v=2] run(2)
[hide] cleanup(2) [show]              ← 再実行されない
[hide] [v=3 while hidden] [show]      ← 非表示中の変更も取りこぼす

問題は2つあります。

  1. 非表示時にクリーンアップだけ走り、再表示で元に戻らない
    effect で購読やタイマーを張っていた場合、再表示後は解除されたままになります。
  2. 非表示中の deps 変更を取りこぼす
    再表示が「初回マウント扱い」になるため、v=3 に対する effect が一度も実行されません。

StrictMode の疑似再マウントと Activity の再表示は、React の設計上意図的に同じ扱いになっています。そのため「StrictMode ではスキップ、Activity の再表示では実行」をマウントフラグで区別することはできません。

Activity でも正しく動かしたい場合

「初回」をフラグではなく、**「まだ一度も実行していない」かつ「マウント時から deps が変わっていない」**と定義し直すと、両方に対応できます。

import { useEffect, useRef } from 'react'
import type { DependencyList, EffectCallback } from 'react'

const areDepsEqual = (a: DependencyList, b: DependencyList) =>
  a.length === b.length && a.every((v, i) => Object.is(v, b[i]))

export function useUpdateEffect(effect: EffectCallback, deps: DependencyList) {
  const mountDepsRef = useRef<DependencyList | null>(null)
  const hasRunRef = useRef(false)

  useEffect(() => {
    if (!hasRunRef.current) {
      // 最初のコミット:deps を記録してスキップ
      if (mountDepsRef.current === null) {
        mountDepsRef.current = deps
        return
      }
      // StrictMode の再実行など、deps が変わっていなければスキップ
      if (areDepsEqual(mountDepsRef.current, deps)) return
    }
    hasRunRef.current = true
    return effect()
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps)
}

一度実行した後は通常の useEffect と同じ振る舞いになるため、クリーンアップと再実行の対称性が保たれます。

// StrictMode
[mount v=1] [update v=2] run(2)

// Activity
[mount v=1] [update v=2] run(2)
[hide] cleanup(2) [show] run(2)                   ← 再表示で復元 ✅
[hide] cleanup(2) [v=3 while hidden] [show] run(3) ← 取りこぼさない ✅

// Activity(一度も更新されないまま非表示にした場合)
[mount v=1] [hide] [show]                          ← 初回扱いのまま ✅
[hide] [v=2 while hidden] [show] run(2)            ← 変更は拾う ✅

なお、この版は deps の比較が前提のため、deps を必須にしています(deps 省略時は StrictMode の再実行と区別できないため)。

どちらを使うべきか

用途 おすすめ
Activity を使わない/effect にクリーンアップがない 2 effect 版で十分
Activity 配下で使う/購読・タイマーなどクリーンアップ必須の処理 deps 比較版
  • StrictMode はマウント直後に「アンマウント → 再マウント」を疑似実行するが、ref の値は残る
  • そのため「1つの effect 内で ref を立てる」実装は、2回目を更新と誤認して壊れる
  • 依存配列 [] の effect のクリーンアップで「初回扱い」に戻すと解決できる
  • ただし <Activity> の再表示も同じ扱いになるため、再表示時の挙動まで気にするなら「まだ一度も実行していないか」で判定する

StrictMode の二重実行は「邪魔な挙動」ではなく、「effect が途中で止められて再開しても壊れないか」を確かめる仕組みです。Activity の登場でそれが本番でも起こるようになったので、カスタムフックを書くときは「再マウントされたらどうなるか」を一度考えてみてください。

プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼
https://projisou.jp

4

いいねしたユーザー一覧へ移動

0
comment1

コメント一覧へ移動

新規登録して、もっと便利にQiitaを使ってみよう

  1. あなたにマッチした記事をお届けします
  2. 便利な情報をあとで効率的に読み返せます
  3. ダークテーマを利用できます
ログインすると使える機能について
4

いいねしたユーザー一覧へ移動

0

一度削除した記事は復旧できません。

この記事の編集中の下書きも削除されます。

削除してよろしいですか?

元のページを確認する

必要な場合のみ、広告などを含む配信元ページをフレームで表示できます。