4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【React】「1つのuseEffect内でrefを立てる」useUpdateEffectはStrictModeで壊れる

4
Posted at

はじめに

「初回マウント時は実行せず、依存配列が変わったときだけ実行したい」という場面で、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のメンバー募集中!

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

4
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?