useEffect
この章では、副作用(Side Effect)を扱うuseEffectフックについて学びます。
この章で学ぶこと
- 副作用とは何か、なぜuseEffectが必要か
- 依存配列による実行タイミングの制御
- クリーンアップ関数の使い方
- よくある間違いと対処法
この章では03章で作成したプロジェクトを使用します。npm run devで開発サーバーを起動し、コードを書きながら学習を進めましょう。
副作用とは
副作用とは、コンポーネントのレンダリング以外で発生する処理のことです。
- APIからのデータ取得
- DOMの直接操作
- タイマーの設定
- イベントリスナーの登録
- ローカルストレージへのアクセス
Reactは「純粋なレンダリング」を推奨しています。純粋なレンダリングとは、同じpropsとstateに対して常に同じ結果を返すことです。useEffectは、この「純粋さ」の外側で行う処理(副作用)を安全に実行するための仕組みです。
useEffectの基本
import { useEffect, useState } from 'react'
function Example() {
const [count, setCount] = useState(0)
const handleIncrement = () => {
setCount(count + 1)
}
// useEffect: レンダリング後に実行される副作用を記述
// 第1引数: 実行する関数
// 第2引数: 依存配列(省略すると毎回実行)
useEffect(() => {
// ブラウザのタブタイトルを更新(副作用の例)
document.title = `カウント: ${count}`
})
return (
<div>
<p>{count}</p>
<button onClick={handleIncrement}>+1</button>
</div>
)
}
依存配列
useEffectの第2引数には、依存配列を指定します。
依存配列なし
毎回のレンダリング後に実行されます。
useEffect(() => {
console.log('毎回実行')
})
空の依存配列
マウント時に1回だけ実行されます。
useEffect(() => {
console.log('マウント時のみ実行')
}, []) // 空の配列 → コンポーネントがマウントされた時に1回だけ実行
StrictMode(03章参照)では、開発時にuseEffectが2回実行されます。これはバグではなく、クリーンアップ処理が正しく実装されているかをチェックするためです。本番環境では1回しか実行されません。
依存する値を指定
指定した値が変更されたときに実行されます。
useEffect(() => {
console.log(`countが変更されました: ${count}`)
}, [count]) // count が変わったときだけ実行
試してみよう: ドキュメントタイトル更新
カウンターコンポーネントを作成し、カウント値をブラウザのタブタイトルに反映してみましょう。
ヒント
useStateでカウント値を管理useEffect内でdocument.titleを更新- 依存配列に
countを指定
回答と解説
import { useState, useEffect } from 'react'
import './App.css'
function DocumentTitle() {
const [count, setCount] = useState(0)
const handleIncrement = () => {
setCount(prev => prev + 1)
}
useEffect(() => {
document.title = `カウント: ${count}`
}, [count])
return (
<div>
<p>カウント: {count}</p>
<button onClick={handleIncrement}>+1</button>
</div>
)
}
export default function App() {
return <DocumentTitle />
}
依存配列にcountを指定することで、countが変更されたときだけdocument.titleが更新されます。ブラウザのタブを確認すると、カウント値がタイトルに反映されているのがわかります。
クリーンアップ関数
useEffectから関数を返すと、クリーンアップ処理として実行されます。
useEffect(() => {
const handleResize = () => {
console.log('リサイズ')
}
// イベントリスナーを登録
window.addEventListener('resize', handleResize)
// クリーンアップ関数: return で関数を返す
// コンポーネントがアンマウントされる時、または
// 依存配列の値が変わってエフェクトが再実行される前に呼ばれる
return () => {
// 登録したイベントリスナーを解除(メモリリーク防止)
window.removeEventListener('resize', handleResize)
}
}, [])
実践例
タイマー
function Timer() {
const [seconds, setSeconds] = useState(0)
useEffect(() => {
// setInterval: 指定ミリ秒ごとに関数を繰り返し実行
// 戻り値はタイマーID(停止時に使用)
const interval = setInterval(() => {
setSeconds(prev => prev + 1)
}, 1000) // 1000ミリ秒 = 1秒
// クリーンアップ: タイマーを停止
return () => clearInterval(interval)
}, []) // 空配列 → マウント時に1回だけ開始
return <p>経過時間: {seconds}秒</p>
}
試してみよう: ストップウォッチ
開始/停止ボタンで制御できるストップウォッチを作成し、クリーンアップ処理も正しく実装してみましょう。
ヒント
isRunningのStateで開始/停止を制御useEffectの依存配列にisRunningを指定- クリーンアップで
clearIntervalを呼ぶ - リセットボタンも追加すると便利
回答と解説
import { useState, useEffect, useRef } from 'react'
import './App.css'
function Stopwatch() {
const [seconds, setSeconds] = useState(0)
const [isRunning, setIsRunning] = useState(false)
const intervalRef = useRef<number | null>(null)
useEffect(() => {
if (isRunning) {
intervalRef.current = window.setInterval(() => {
setSeconds(prev => prev + 1)
}, 1000)
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
}
}
}, [isRunning])
const handleStartStop = () => {
setIsRunning(prev => !prev)
}
const handleReset = () => {
setIsRunning(false)
setSeconds(0)
}
return (
<div>
<p>{seconds}秒</p>
<button onClick={handleStartStop}>
{isRunning ? '停止' : '開始'}
</button>
<button onClick={handleReset}>リセット</button>
</div>
)
}
export default function App() {
return <Stopwatch />
}
isRunningの状態変化に応じてsetIntervalを開始/停止します。クリーンアップ関数で必ずclearIntervalを呼ぶことで、コンポーネントのアンマウント時やエフェクト再実行時にタイマーが適切に解除されます。
ローカルストレージとの同期
function useLocalStorage(key: string, initialValue: string) {
const [value, setValue] = useState(() => {
const saved = localStorage.getItem(key)
return saved ?? initialValue
})
useEffect(() => {
localStorage.setItem(key, value)
}, [key, value])
return [value, setValue] as const
}
function App() {
const [name, setName] = useLocalStorage('name', '')
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value)
}
return (
<input
value={name}
onChange={handleChange}
placeholder="名前を入力"
/>
)
}
ウィンドウサイズの監視
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
})
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
})
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [])
return size
}
function App() {
const { width, height } = useWindowSize()
return <p>画面サイズ: {width} x {height}</p>
}
試してみよう: ウィンドウサイズ表示
現在のウィンドウサイズを表示するコンポーネントを作成してみましょう。
ヒント
useStateでwidth/heightを管理window.addEventListener('resize', handler)でリサイズを監視- クリーンアップで
removeEventListenerを呼ぶ - 空の依存配列
[]でマウント時に1回だけ登録
回答と解説
import { useState, useEffect } from 'react'
import './App.css'
function WindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight
})
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight
})
}
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
return (
<div>
<p>幅: {size.width}px</p>
<p>高さ: {size.height}px</p>
</div>
)
}
export default function App() {
return <WindowSize />
}
空の依存配列[]を指定することで、マウント時に1回だけイベントリスナーを登録します。クリーンアップ関数でリスナーを解除することで、メモリリークを防いでいます。ブラウザウィンドウをリサイズすると、表示が更新されます。
useEffectの実行タイミング
1. レンダリング(DOMの計算)
2. DOM更新
3. useEffect実行
useEffectはレンダリング後に非同期で実行されます。レンダリングをブロックしないため、パフォーマンスに影響を与えにくいです。
よくある間違い
無限ループ
レンダー中に作ったオブジェクトや配列を依存配列に渡すと、毎回新しい参照になるため、エフェクトがレンダーのたびに再実行されます。さらにエフェクト内でStateを更新していると、再レンダー → 再実行のループが止まらなくなります(無限ループ)。
// NG: 無限ループ
useEffect(() => {
fetchData(options)
}, [options]) // optionsが毎回新しいオブジェクト
// OK: 必要なプロパティのみ依存させる
useEffect(() => {
fetchData({ page, limit })
}, [page, limit])
依存配列の漏れ
ESLintのexhaustive-depsルールを有効にして、依存配列の漏れを防ぎましょう。
// NG: countが依存配列にない
useEffect(() => {
console.log(count)
}, [])
// OK
useEffect(() => {
console.log(count)
}, [count])
エフェクトから最新の値を読みたいだけのとき: useEffectEvent(React 19.2 以降)
「エフェクトの中で最新のpropsやstateを読みたいが、その値の変化ではエフェクトを再実行したくない」というケースがあります。たとえばチャットルームに接続するエフェクトで、roomIdが変わったら再接続したい一方、通知の表示に使うthemeが変わっただけでは再接続したくない、という場合です。
themeを依存配列に入れると、テーマを切り替えるたびに再接続が走ってしまいます。かといってthemeを依存配列から外すと、ESLintのexhaustive-depsルールに警告されるうえ、接続時点の古いthemeを参照し続けることになります。
このようなときは、React 19.2で安定版になったuseEffectEventを使います。useEffectEventでラップした関数(エフェクトイベント)は、呼び出された時点の最新のpropsやstateを常に読み取れます。
import { useEffect, useEffectEvent } from 'react'
// createConnection(チャット接続)と showNotification(通知表示)は
// 別ファイルで定義されている想定
function ChatRoom({ roomId, theme }: { roomId: string; theme: string }) {
// エフェクトイベント: 中で参照する theme は常に最新の値になる
const onConnected = useEffectEvent(() => {
showNotification('接続しました', theme)
})
useEffect(() => {
const connection = createConnection(roomId)
connection.on('connected', () => {
onConnected()
})
connection.connect()
return () => connection.disconnect()
}, [roomId]) // theme を依存配列に入れる必要がない → theme の変更では再接続されない
return <p>ルーム: {roomId}</p>
}
useEffectEventには制約が2つあります。
- エフェクト内からのみ呼べる: イベントハンドラ内やレンダー中で呼び出すことはできません
- 依存配列に入れない: エフェクトイベントは「リアクティブではない」扱いのため、依存配列から除外します
データフェッチングの注意点
useEffectでのデータフェッチングは基本的なパターンですが、本番アプリケーションではTanStack Queryなどのライブラリを使用することを推奨します。※TanStack Queryについては20章で詳しく解説します。
// 基本的なパターン(学習用)
type User = { id: number; name: string }
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
// ignore フラグ: このエフェクト実行が古くなった(クリーンアップ済み)かどうか
// アンマウント時と、userId が変わって再実行される前の両方で true になる
let ignore = false
async function fetchUser() {
try {
setLoading(true)
const response = await fetch(`/api/users/${userId}`)
const data = await response.json()
// 古くなった実行では状態更新しない(古いデータでの上書き防止)
if (!ignore) {
setUser(data)
}
} catch (e) {
if (!ignore) {
setError(e as Error)
}
} finally {
if (!ignore) {
setLoading(false)
}
}
}
fetchUser()
// クリーンアップ: フラグを立てて状態更新を防ぐ
// これがないと、ユーザー切り替え時に古いデータが表示される可能性がある
return () => {
ignore = true
}
}, [userId]) // userId が変わるたびにデータを再取得
if (loading) return <p>読み込み中...</p>
if (error) return <p>エラー: {error.message}</p>
if (!user) return <p>ユーザーが見つかりません</p>
return <p>{user.name}</p>
}
AbortController でリクエストをキャンセルする
上記の ignore フラグは状態更新を抑止するだけで、実際の通信は最後まで行われます。帯域や CPU を節約したい場合は、AbortController でリクエスト自体を中断するのが望ましい方法です。
useEffect(() => {
const controller = new AbortController()
async function fetchUser() {
try {
setLoading(true)
const response = await fetch(`/api/users/${userId}`, {
signal: controller.signal,
})
const data = await response.json()
setUser(data)
} catch (e) {
// AbortError はキャンセル時の想定内エラーなので無視
if ((e as Error).name === 'AbortError') return
setError(e as Error)
} finally {
// abort された実行はここでも状態を触らない(新しいリクエストの「読み込み中」表示を消さないため)
if (!controller.signal.aborted) {
setLoading(false)
}
}
}
fetchUser()
return () => {
controller.abort() // 実際のHTTPリクエストを中断
}
}, [userId])
AbortController を使うと、userId が素早く切り替わったときに古いリクエストを本当にキャンセルできます。ignore フラグ方式はシンプルで分かりやすいので学習用には適していますが、実務では AbortController を使うのが基本です。多くのデータ取得ライブラリ(TanStack Query など)も内部で AbortController を活用しています。
useEffect を使わない選択肢
React 公式ドキュメントでも「useEffect は必要最小限にとどめる」ことが推奨されています。以下のケースは useEffect ではなく、より適切な手段で解決できます。
派生した値はレンダー中に計算する
「他の state から計算できる値」を useEffect + useState で管理するのは冗長です。毎レンダーで計算すれば十分です。計算が重い場合は React Compiler の自動メモ化や、useMemo による手動メモ化で最適化できます(22章 パフォーマンス参照)。
// ❌ useEffect で派生値を同期する(不要)
const [fullName, setFullName] = useState('')
useEffect(() => {
setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])
// ✅ レンダー中に計算する
const fullName = `${firstName} ${lastName}`
イベントに伴う処理はハンドラに書く
ボタンクリックやフォーム送信のように「ユーザー操作に反応する処理」は、イベントハンドラに書くのが自然です。useEffect はレンダーの結果として実行される処理用で、イベント駆動には向きません。
// ❌ 送信完了を useEffect で検知する
useEffect(() => {
if (isSubmitted) sendAnalytics('form_submitted')
}, [isSubmitted])
// ✅ 送信ハンドラ内で直接呼ぶ
async function handleSubmit() {
await submitForm()
sendAnalytics('form_submitted')
}
外部ストアは useSyncExternalStore
Zustand や Redux のような外部ストア、またはブラウザ API(window.matchMedia など)を購読する場合、useState + useEffect で手組みするより useSyncExternalStore を使うのが正解です。並行レンダリングとの整合が自動的に取れます(詳細は 15章 カスタムフック で解説)。
Promise の結果は use(Promise)(React 19+)
親コンポーネントや Server Components から Promise を受け取る場合は、useEffect で解決せず use hook で直接読み取れます。Suspense 境界に委ねてローディング管理を簡潔にできます。
import { use } from 'react'
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise) // Suspense がローディングを扱う
return <p>{user.name}</p>
}
ただし、use(Promise) にはキャッシュも再取得機能もありません。SPA で API データを管理するなら、20章の TanStack Query(useSuspenseQuery)の方が向いています。
まとめ
useEffectは副作用を扱うフック- 依存配列で実行タイミングを制御
- クリーンアップ関数でリソースを解放
- 依存配列の漏れに注意(ESLintルール活用)
- データフェッチングは専用ライブラリの使用を検討
次の章では、useRefとDOM操作について学びます。