はじめに
「初回マウント時は実行せず、依存配列が変わったときだけ実行したい」という場面で、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 は宣言順に実行されます。初回マウント時は
- ①が
trueを見てスキップ - ②が
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つあります。
-
非表示時にクリーンアップだけ走り、再表示で元に戻らない
effect で購読やタイマーを張っていた場合、再表示後は解除されたままになります。 -
非表示中の 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 の登場でそれが本番でも起こるようになったので、カスタムフックを書くときは「再マウントされたらどうなるか」を一度考えてみてください。
参考
<StrictMode>– React- How to handle the Effect firing twice in development – React
<Activity>– React- eslint-plugin-react-hooks – npm
JISOUのメンバー募集中!
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
日本一のアウトプットコミュニティでキャリアアップしませんか?
興味のある方は、ぜひホームページをのぞいてみてください!
▼▼▼
https://projisou.jp
