症状:useEffect 内の API が開発環境で2回呼ばれる
React 18 にアップグレードした(または新規プロジェクトを作成した)直後から、こんな現象に遭遇することがあります。
- ブラウザの DevTools を開くと、同じ API エンドポイントへのリクエストが 2件 表示される
- データが重複してデータベースに登録される
- コンソールにログが2行出力される
- WebSocket が2本張られる
コードを見ても、useEffect は1つしか書いていないのになぜ?と頭を抱えるケースです。
原因:React 18 StrictMode の仕様変更
React 18 から、開発環境(development mode)に限り、StrictMode が「コンポーネントのアンマウント→リマウント」を意図的に行うようになりました。
In the future, we’d like to add a feature that allows React to add and remove sections of the UI while preserving state. — React公式ブログ
具体的には以下のサイクルが実行されます。
1. コンポーネントをマウント(useEffect が実行される)
2. コンポーネントをアンマウント(クリーンアップ関数が実行される)
3. コンポーネントを再マウント(useEffect が再度実行される)
これにより、副作用のクリーンアップが正しく実装されているかどうかを検証するためのものです。
本番環境では起きない
この二重実行は 開発環境(npm run dev)のみ で発生します。npm run build でビルドした本番バイナリでは、StrictMode の二重実行は行われません。
つまり、本番で問題が起きていないなら「バグ」ではなく「潜在的な問題の検出」と捉えるべきです。
症状別:何が起きているか確認する
パターン1:コンソールログが2行出る
useEffect(() => {
console.log('effect ran') // 開発環境では2回表示される
}, [])
これは問題ではありません。StrictMode の正常動作です。
パターン2:fetch が2回呼ばれる(GET)
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(data => setUsers(data))
}, [])
2回呼ばれますが、GET リクエストが冪等であれば実害はありません。ただし、クリーンアップが実装されていないため、最初の fetch の結果が返ってきたときにコンポーネントがアンマウント済みの場合、state 更新が行われてメモリリークになります。
パターン3:POST・登録系 API が2回実行される(危険)
useEffect(() => {
// ページ表示時にアクセスログを記録 → 開発環境では2件登録される
fetch('/api/access-log', { method: 'POST', body: JSON.stringify({ page: '/top' }) })
}, [])
これは実際に問題になります。開発中に重複データが生成されます。
正しい対処法
対処法1:AbortController でキャンセル処理を実装する(推奨)
クリーンアップ関数で前の fetch をキャンセルするのが、最も React の設計思想に沿った方法です。
useEffect(() => {
const controller = new AbortController()
fetch('/api/users', { signal: controller.signal })
.then(res => res.json())
.then(data => setUsers(data))
.catch(err => {
if (err.name === 'AbortError') return // キャンセルは無視
console.error(err)
})
return () => {
controller.abort() // アンマウント時に前のリクエストをキャンセル
}
}, [])
React が最初のマウント→アンマウントを行うとき、クリーンアップで abort() が呼ばれるため、1回目の fetch はキャンセルされます。2回目のマウント時の fetch だけが残ります。
対処法2:async/await + AbortController(TypeScript対応版)
useEffect(() => {
const controller = new AbortController()
const fetchUsers = async () => {
try {
const res = await fetch('/api/users', { signal: controller.signal })
const data: User[] = await res.json()
setUsers(data)
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return
console.error('fetch error:', err)
}
}
fetchUsers()
return () => controller.abort()
}, [])
対処法3:データフェッチは SWR / React Query に委譲する(最推奨)
そもそも useEffect でデータフェッチするのは、React のベストプラクティスから外れています。SWR や TanStack Query(React Query)はリクエストの重複排除・キャンセル・キャッシュを内部で処理してくれます。
// SWR の場合
import useSWR from 'swr'
const fetcher = (url: string) => fetch(url).then(res => res.json())
function UserList() {
const { data: users, error, isLoading } = useSWR<User[]>('/api/users', fetcher)
if (isLoading) return <div>Loading...</div>
if (error) return <div>Error</div>
return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
// TanStack Query の場合
import { useQuery } from '@tanstack/react-query'
function UserList() {
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => fetch('/api/users').then(res => res.json()),
})
if (isLoading) return <div>Loading...</div>
return <ul>{users?.map((u: User) => <li key={u.id}>{u.name}</li>)}</ul>
}
やってはいけない対処法
NG:useRef でフラグを立てて二重実行を防ぐ
ネット上によく載っているこのパターンは非推奨です。
// ❌ やってはいけない
useEffect(() => {
const ran = useRef(false) // これはエラー(Hooksのルール違反)
}, [])
// ❌ これもやってはいけない(useRef は外に出す必要があるが、それでも非推奨)
const initialized = useRef(false)
useEffect(() => {
if (initialized.current) return
initialized.current = true
fetch('/api/data') // 1回しか呼ばれなくなるが…
}, [])
このアプローチの問題点:
- React が「クリーンアップが正しく実装されているか」を検証できなくなる
- 将来的に React が追加予定の Activity API(オフスクリーンレンダリング) と相性が悪い
- 本番環境でのメモリリーク・競合状態を見つけにくくなる
NG:StrictMode を外す
// ❌ やってはいけない
root.render(
// <React.StrictMode> ← コメントアウトするのは最終手段
<App />
// </React.StrictMode>
)
StrictMode は開発中の問題を早期発見するためのものです。外すことで「二重実行」は消えますが、本来 React が検出してくれていたバグを見逃すことになります。
WebSocket・EventSource が2本張られる場合
WebSocket やサーバー送信イベント(SSE)も同様に2本張られます。この場合もクリーンアップが重要です。
useEffect(() => {
const ws = new WebSocket('wss://example.com/ws')
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
setMessages(prev => [...prev, data])
}
return () => {
ws.close() // ← これがないと接続が2本のまま残る
}
}, [])
クリーンアップ関数で ws.close() を呼んでいれば、1回目のマウント時に接続が張られ→アンマウント時に閉じられ→2回目のマウント時に1本の接続が残ります。
まとめ
| 状況 | 対処 |
|---|---|
| GET リクエストが2回飛ぶ | AbortController でキャンセル実装 or SWR/React Query |
| POST など副作用リクエストが2回飛ぶ | クリーンアップ関数でキャンセル、設計を見直す |
| WebSocket が2本張られる | クリーンアップ関数で close() する |
| コンソールログが2行出る | 仕様通り。特に対処不要 |
| 本番で問題が起きている | StrictMode とは無関係の別の原因を調査 |
React 18 の StrictMode 二重実行は「バグ検出機能」です。二重実行によって問題が表面化したということは、クリーンアップが不足していることを意味します。useRef フラグで握り潰すのではなく、AbortController や SWR/React Query を使って副作用を正しく管理することが、長期的に安定したコードにつながります。
