フックのルールとよくある落とし穴
1. 学習内容
- フックの2つの鉄則(なぜ条件付きで呼び出せないのか)
- 古いクローズの特定と修正
useEffectで無限ループが発生する5つの理由- useState を使用した非同期更新の落とし穴
- 最もよくある10のバグに対する解決策
2. デバッグの悪夢の物語
(1) 課題:たった1つのuseEffectのデバッグに3時間もかかってしまった
アリスはカウントダウンコンポーネントを作成しました:
(1) ▶ サンプル:Hooks Rule デモ
function Countdown({ targetDate }) {
const [timeLeft, setTimeLeft] = useState(computeTimeLeft())
function computeTimeLeft() {
return Math.max(0, Math.floor((new Date(targetDate) - Date.now()) / 1000))
}
useEffect(() => {
const timer = setInterval(() => {
// ❌ Closure Pitfalls:timeLeft Always the initial value
setTimeLeft(timeLeft - 1)
}, 1000)
return () => clearInterval(timer)
}, []) // I want it to start only when mounted
return <p>Remaining:{timeLeft}s</p>
}
バグ:カウントダウンは常に初期値の-1で停止してしまう。これは、setIntervalのコールバックで取得されるtimeLeftが、最初のレンダリング時の値であるためである(クロージャートラップ)。
(2) 修正
// ✅ Fix 1:Functional Updates
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(prev => Math.max(0, prev - 1)) // prev Always the latest value
}, 1000)
return () => clearInterval(timer)
}, [])
// ✅ Fix 2:Or put targetDate Add to the dependencies array
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(prev => Math.max(0, prev - 1))
}, 1000)
return () => clearInterval(timer)
}, [targetDate]) // targetDate Restart the timer when a change occurs
レッスン:useEffect で状態を直接読み取って自身を更新してはいけません。関数による更新 setState(prev => prev + 1) を使用してください。
3. フックの2つの鉄則
(1) ルール 1:フックは最上位レベルでのみ呼び出す
ループ、条件文、またはネストされた関数内では、フックを呼び出さないでください:
function BadComponent({ showExtra }) {
const [name, setName] = useState('') // ✅ Top Floor
// ❌ Rule Violation:Called within a condition Hook
if (showExtra) {
const [extra, setExtra] = useState('') // Error!
useEffect(() => { /* ... */ }, [])
}
// ❌ Rule Violation:Called within a loop Hook
for (let i = 0; i < items.length; i++) {
useEffect(() => { /* ... */ }, []) // Error!
}
// ✅ Correct:Conditional logic in Hook Internal Processing
const [extra, setExtra] = useState('')
useEffect(() => {
if (showExtra) { /* Conditional logic is placed in useEffect Inside */ }
}, [showExtra])
}
| 発信元 | 有効 | 理由 |
|---|---|---|
| トップレベルの関数コンポーネント | ✅ | フックチェーンの順序は、レンダリングのたびに一貫して維持される |
| トップレベルのカスタムフック | ✅ | カスタムフックは、本質的には関数コンポーネントのサブコールチェーンである |
| if / for / while の内部 | ❌ | 条件の変化によりフックの呼び出し回数が一貫性を失い、リンクリストの位置ずれが生じる |
| 汎用ユーティリティ関数 | ❌ | Reactコンポーネントのコンテキスト外;フックのチェーンなし |
| クラスコンポーネントのメソッド | ❌ | クラスコンポーネントにはフックチェーンの仕組みがない |
| useEffect のコールバック内部 | ❌ | コールバックはコンポーネントの最上位レベルにないため、呼び出し順序が予測できない |
(2) ルール 2: フックは関数コンポーネントまたはカスタムフック内でのみ呼び出す
// ❌ Called from a regular function Hook
function getData() {
const [data, setData] = useState(null) // Error!
}
// ❌ Calling within a class component Hook
class MyClass extends React.Component {
render() {
const [count, setCount] = useState(0) // Error!
}
}
// ✅ Custom Hook Called in
function useCustomHook() {
const [data, setData] = useState(null) // ✅ Correct
}
(3) なぜこの2つのルールがそれほど重要なのでしょうか?
graph TB
A[Component Rendering] --> B[Hook Linked List]
B --> C[Hook 1: useState<br/>Linked List Nodes 1]
B --> D[Hook 2: useEffect<br/>Linked List Nodes 2]
B --> E[Hook 3: useRef<br/>Linked List Nodes 3]
D -->|Conditional rendering causes skipping| F[Linked List Misalignment!]
F --> G[Hook 2 What I read was Hook 3 data]
G --> H[Bug! Data Chaos]
style F fill:#ff4d4f,color:#fff
style H fill:#ff4d4f,color:#fff
Reactは、状態を同期させるためにフックの呼び出し順序に依存しています。レンダリングのたびに呼び出し順序が異なると、状態の同期が崩れてしまいます。
4. 落とし穴 1:古いクロージャーの落とし穴
最もよくある Hooks のバグ。
function Counter() {
const [count, setCount] = useState(0)
// ❌ Closure Pitfalls
useEffect(() => {
const timer = setInterval(() => {
console.log(count) // Always 0!
setCount(count + 1) // Set permanently to 1
}, 1000)
return () => clearInterval(timer)
}, []) // count is "locked" to first render
// ✅ Fix 1:Functional Updates
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1) // prev Always the latest value
}, 1000)
return () => clearInterval(timer)
}, [])
// ✅ Fix 2:Add Dependencies
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(timer)
}, [count]) // count Restart the timer when a change occurs
}
発生時:useEffect、useCallback、またはuseMemoで、dependencies配列に宣言されていない値が読み込まれました。
| クロージャーの落とし穴となるシナリオ | 症状 | 解決策 |
|---|---|---|
| setInterval の状態の読み取り | タイマーの値がまったく変わらない | 機能の更新 setState(prev => ...) |
useCallback での状態の読み取り |
コールバック内で古い値を使用 | 依存関係配列への状態の追加、または ref の使用 |
| イベントハンドラの実行が遅延している | 非同期コールバック内の state の値が古くなっている |
useRef を使用して最新の値を保存してください |
| useEffect の依存関係が不足しています | エフェクト内で使用されている変数が更新されていません | 依存関係配列を完成させてください |
| 非同期リクエスト後にレースコンディションが発生する | 高速切り替え時に、古いリクエストが新しい結果を上書きしてしまう | ignore フラグまたは AbortController を使用する |
トラブルシューティングのヒント: コールバック内で読み込まれる変数はすべて、dependencies 配列で宣言されている必要があります。あるいは、関数型の更新を使用してください。
5. 落とし穴 2:useEffect の無限ループ
(1) 理由 1:依存関係の配列がない
// ❌ Infinite Loop!
useEffect(() => {
fetch('/api/data').then(setData)
}) // No array dependencies → Executed on every render → setData Trigger Rendering → Infinite Loop
// ✅ Empty array of dependencies
useEffect(() => {
fetch('/api/data').then(setData)
}, [])
(2) 理由 2:依存関係配列内の参照型
function App() {
const [count, setCount] = useState(0)
// ❌ Infinite Loop!
useEffect(() => {
console.log('useEffect Execute')
}, [{ count }]) // Create a new object on every render {} → Differences in Citation → Infinite Execution
// ✅ Correct:Using Primitive Types
useEffect(() => {
console.log('useEffect Execute')
}, [count]) // Basic Types,If the value remains unchanged, the action is not performed.
}
(3) 理由 3:useEffect 内の依存関係を変更する
const [items, setItems] = useState([1, 2, 3])
// ❌ Infinite Loop!
useEffect(() => {
setItems([...items, 4]) // Edit items → Render Again → useEffect Execute again...
}, [items])
// ✅ Correct:Why is this change necessary? items?Generally, it is not necessary.
// If it's initialization,use useRef Flag indicating whether initialization has been completed
const initialized = useRef(false)
useEffect(() => {
if (!initialized.current) {
setItems([...items, 4])
initialized.current = true
}
}, [items])
(4) 理由 4:関数としての依存関係
| 無限ループの原因 | 誤ったコード | 正しいコード |
|---|---|---|
| 配列への依存関係なし | useEffect(fn) |
useEffect(fn, []) |
| 参照型 | useEffect(fn, [{ count }]) |
useEffect(fn, [count]) |
effect で依存関係を変更する |
useEffect(() => setItems(...), [items]) |
useRef で初期化をマークする |
| 依存関係としての関数 | useEffect(fn, [handleClick]) |
useCallback 安定した参照 |
| 依存関係としてのオブジェクト | useEffect(fn, [config]) |
プリミティブ型の値を抽出するか、useMemo を使用する |
// ❌ Each render creates a new function reference
function App() {
const handleClick = () => { /* ... */ }
useEffect(() => {
fetch('/api').then(setData)
}, [handleClick]) // handleClick A new reference every time → Infinite Execution
// ✅ use useCallback Stable Citation
const handleClick = useCallback(() => { /* ... */ }, [])
useEffect(() => {
fetch('/api').then(setData)
}, [handleClick]) // handleClick Citation unchanged,Will not be executed again
}
6. 落とし穴 3:useState による非同期更新
function Counter() {
const [count, setCount] = useState(0)
function handleClick() {
// You thought it would +3,The result was only +1
setCount(count + 1)
setCount(count + 1)
setCount(count + 1) // All three times count + 1, but count = 0
console.log(count) // Output 0,No 3
}
// ✅ Functional Updates
function handleCorrectClick() {
setCount(prev => prev + 1) // prev = 0 → 1
setCount(prev => prev + 1) // prev = 1 → 2
setCount(prev => prev + 1) // prev = 2 → 3
// count Or 0(The rendered values for this pass),But the next time it's rendered, it will be 3
}
}
重要な概念:useStateへの更新は非同期かつバッチ処理されます。1回のイベント処理サイクル中に、複数のsetCountインスタンスが1つのレンダリングにまとめられます。機能的な更新は、すべての更新が最新の値に基づいて行われることを保証するために使用されます。
7. 落とし穴 4:useEffect 内で async を使用すること
// ❌ useEffect Cannot be transferred directly async Function
useEffect(async () => {
const data = await fetch('/api/data')
setData(await data.json())
}, [])
// Error!async returns Promise, useEffectt Expected return value of the cleanup function(or undefined)
// ✅ Correct:Defined internally async Function
useEffect(() => {
async function fetchData() {
const res = await fetch('/api/data')
const data = await res.json()
setData(data)
}
fetchData() // Internal Call async Function
}, [])
8. 完全な例:10の一般的なバグ診断表
// ============================================
// Example:10 A common Hooks Bug + Remediation Plan
// ============================================
// Bug 1:Conditions Hook
function Bug1({ show }) {
// if (show) const [x] = useState(0) // ❌
const [x] = useState(0) // ✅ Move to the top
}
// Bug 2:Closure Pitfalls
function Bug2() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
// setCount(count + 1) // ❌ Closure
setCount(prev => prev + 1) // ✅
}, 1000)
return () => clearInterval(id)
}, [])
}
// Bug 3:Infinite Loop(Dependency on Reference Types)
function Bug3() {
const [data, setData] = useState(null)
useEffect(() => {
fetch('/api').then(res => res.json()).then(setData)
}, []) // ✅ Empty array
// }, [data]) // ❌ data The change triggered another one useEffect
}
// Bug 4:Forgot to clean up
function Bug4() {
useEffect(() => {
const id = setInterval(() => console.log('tick'), 1000)
return () => clearInterval(id) // ✅ Must be cleaned up
}, [])
}
// Bug 5:Edit directly state
function Bug5() {
const [user, setUser] = useState({ name: 'Alice', age: 28 })
function updateAge() {
// user.age = 29 // ❌ Edit directly
setUser({ ...user, age: 29 }) // ✅ Create a New Object
}
}
// Bug 6:Not needed useMemo
function Bug6({ count }) {
// const doubled = useMemo(() => count * 2, [count]) // ❌ Unnecessary
const doubled = count * 2 // ✅ Simple calculations are not required useMemo
}
// Bug 7:effect Read from props But do not declare dependencies
function Bug7({ userId }) {
const [user, setUser] = useState(null)
useEffect(() => {
fetch('/api/users/' + userId).then(res => res.json()).then(setUser)
}, [userId]) // ✅ userId Dependencies must be added
// }, []) // ❌ userId Changes will not trigger a new request
}
// Bug 8:useMemo Return Function
function Bug8() {
// const fn = useMemo(() => { return () => console.log('hi') }, []) // ❌ Should use useCallback
const fn = useCallback(() => console.log('hi'), []) // ✅
}
// Bug 9:in render Side effects caused by the drug
function Bug9() {
const [data, setData] = useState(null)
// fetch('/api/data').then(setData) // ❌ A request is made for every render
useEffect(() => { fetch('/api/data').then(res => res.json()).then(setData) }, []) // ✅
}
// Bug 10:State dependency not fully declared
function Bug10({ items }) {
const [filter, setFilter] = useState('')
// const filtered = items.filter(i => i.includes(filter)) // ✅ in render Calculate directly in the middle
const filtered = useMemo(() => items.filter(i => i.includes(filter)), [items, filter])
// useMemo The correct dependencies must be in place.
}
9. デバッグのヒント
(1) React DevTools プロファイラー
// in React DevTools 's Profiler In the tab:
// 1. Click the Record button
// 2. Triggering Actions in the App
// 3. Stop Recording
// 4. View the flame diagram:Each column is a component,Rendering Time by Height
// I see components that shouldn't be re-rendered being rendered → Inspection useCallback / React.memo
(2) なぜレンダリングしたのか
npm install @welldone-software/why-did-you-render
// src/wdyr.js
import React from 'react'
if (process.env.NODE_ENV === 'development') {
const whyDidYouRender = require('@welldone-software/why-did-you-render')
whyDidYouRender(React, {
trackAllPureComponents: true,
})
}
// The console will print:Why did this component re-render??Because which one Props It's changed?
(3) useWhyDidYouUpdate を使ったデバッグ
function useWhyDidYouUpdate(name, props) {
const previousProps = useRef(props)
useEffect(() => {
const allKeys = Object.keys({ ...previousProps.current, ...props })
const changes = {}
allKeys.forEach(key => {
if (previousProps.current[key] !== props[key]) {
changes[key] = {
from: previousProps.current[key],
to: props[key]
}
}
})
if (Object.keys(changes).length > 0) {
console.log(`[why-did-you-update] ${name}:`, changes)
}
previousProps.current = props
})
}
// Usage
function MyComponent(props) {
useWhyDidYouUpdate('MyComponent', props)
return <div>{props.name}</div>
}
(1) ▶ サンプル:カスタムフックを使用してクロージャの落とし穴を回避する — useStableCallback
// ============================================
// Example:useStableCallback——The Ultimate Solution to Closure Pitfalls
// Features:A callback function that always returns a reference to a stable value,However, the latest logic is executed when the function is called.
// ============================================
import { useRef, useCallback, useState, useEffect } from 'react'
// ---- Custom Hook:A Steady Pullback ----
function useStableCallback(callback) {
// use ref Store the latest callback
const callbackRef = useRef(callback)
// Update after each render ref Callbacks in
useEffect(() => {
callbackRef.current = callback
})
// Return a function that never changes
return useCallback((...args) => {
// Execute the latest version of the callback when it is called
return callbackRef.current(...args)
}, [])
}
// ---- Usage useStableCallback Avoiding Closure Pitfalls ----
function TimerWithStableCallback() {
const [count, setCount] = useState(0)
const [isRunning, setIsRunning] = useState(false)
// This callback function depends on count(Each render is a new function)
function onTick() {
console.log('Current Count:', count)
// If you use it directly here count,This can lead to a closure trap
// but useStableCallback ensures latest version is used.
}
// ✅ use useStableCallback Package,Citations are always reliable
const stableOnTick = useStableCallback(onTick)
useEffect(() => {
if (!isRunning) return
const timer = setInterval(() => {
// stableOnTick The reference will not change,However, the latest version is executed each time it is called. onTick
stableOnTick()
setCount(prev => prev + 1)
}, 1000)
return () => clearInterval(timer)
}, [isRunning, stableOnTick]) // stableOnTick Forever Unchanging,It will not cause an infinite loop
return (
<div style={{ maxWidth: '400px', margin: '0 auto', textAlign: 'center' }}>
<h2>⏱️ Solutions to Closure Pitfalls</h2>
<p style={{ fontSize: '48px', fontWeight: 'bold', margin: '20px 0' }}>{count}</p>
<button
onClick={() => setIsRunning(!isRunning)}
style={{
padding: '10px 24px',
backgroundColor: isRunning ? '#ff4d4f' : '#52c41a',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '16px'
}}
>
{isRunning ? '⏸ Pause' : '▶ Start'}
</button>
<p style={{ marginTop: '12px', color: '#666', fontSize: '13px' }}>
Open the console to view the logs——useStableCallback Ensure that the timer does not get stuck due to closures
</p>
</div>
)
}
// ---- Comparison:Do not use useStableCallback version ----
function TimerBroken() {
const [count, setCount] = useState(0)
const [isRunning, setIsRunning] = useState(false)
useEffect(() => {
if (!isRunning) return
// ❌ Use as is setInterval,count Locked in a closure
const timer = setInterval(() => {
setCount(count + 1) // Always 0 + 1 = 1
}, 1000)
return () => clearInterval(timer)
}, [isRunning]) // Missing count Dependency
// Remediation Plan:Update Using a Function
// setCount(prev => prev + 1) // ✅ Not necessary count Dependency
return (
<div style={{ maxWidth: '400px', margin: '0 auto', textAlign: 'center', marginTop: '40px' }}>
<h3>❌ The version with the issue(For comparison)</h3>
<p style={{ fontSize: '48px', fontWeight: 'bold', margin: '20px 0', color: '#ff4d4f' }}>{count}</p>
<button onClick={() => setIsRunning(!isRunning)}
style={{ padding: '10px 24px', backgroundColor: '#d9d9d9', color: '#333', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
Start(It gets stuck 1)
</button>
</div>
)
}
(2) ▶ サンプル:useState を使用した一括更新における落とし穴と対処法
function ShoppingCart() {
const [items, setItems] = useState([])
const [total, setTotal] = useState(0)
function addItem(item) {
setItems([...items, item])
setTotal(items.reduce((sum, i) => sum + i.price, 0) + item.price)
}
function addItemFixed(item) {
setItems(prev => [...prev, item])
setTotal(prev => prev + item.price)
}
function addMultiple() {
setItems(prev => [...prev, { id: 1, name: 'Book', price: 29 }])
setItems(prev => [...prev, { id: 2, name: 'Pen', price: 5 }])
setTotal(prev => prev + 34)
}
return (
<div>
<h3>Shopping Cart</h3>
<ul>
{items.map((item, idx) => (
<li key={idx}>{item.name} - ${item.price}</li>
))}
</ul>
<p>Total: ${total}</p>
<button onClick={() => addItemFixed({ id: 3, name: 'Notebook', price: 12 })}>
Add Notebook
</button>
<button onClick={addMultiple}>Add Book + Pen</button>
</div>
)
}
(3) ▶ サンプル:useEffect を使用した非同期リクエストにおけるレースコンディション
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
let ignore = false
setLoading(true)
async function fetchUser() {
try {
const res = await fetch(`/api/users/${userId}`)
const data = await res.json()
if (!ignore) {
setUser(data)
setLoading(false)
}
} catch (err) {
if (!ignore) setLoading(false)
}
}
fetchUser()
return () => { ignore = true }
}, [userId])
if (loading) return <p>Loading...</p>
if (!user) return <p>No user found</p>
return (
<div>
<h3>{user.name}</h3>
<p>Email: {user.email}</p>
<p>Role: {user.role}</p>
</div>
)
}
(4) ▶ サンプル:状態の不整合を避けるために、複数の useState 呼び出しの代わりに useReducer を使用する
const initialState = { count: 0, step: 1, history: [] }
function reducer(state, action) {
switch (action.type) {
case 'increment':
return {
...state,
count: state.count + state.step,
history: [...state.history, `+${state.step}`],
}
case 'decrement':
return {
...state,
count: state.count - state.step,
history: [...state.history, `-${state.step}`],
}
case 'setStep':
return { ...state, step: action.payload }
case 'reset':
return initialState
default:
return state
}
}
function CounterWithHistory() {
const [state, dispatch] = useReducer(reducer, initialState)
return (
<div>
<h3>Count: {state.count}</h3>
<label>
Step:
<input
type="number"
value={state.step}
onChange={e => dispatch({ type: 'setStep', payload: Number(e.target.value) })}
/>
</label>
<button onClick={() => dispatch({ type: 'increment' })}>+{state.step}</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-{state.step}</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
<p>History: {state.history.join(', ')}</p>
</div>
)
}
❓ よくある質問
setState を呼び出した後、ページが更新されないことがあるのはなぜですか?state オブジェクトを直接変更した(新しい参照を作成せずに); ② 状態の値が変わらなかった(ReactはObject.isを使って値を比較するため、値が同一の場合はレンダリングがトリガーされない); ③ useEffectのdependencies配列に、更新された変数を宣言し忘れた; ④ 非同期コールバック内で古いクロージャの値を読み取ってしまった。useEffect で、dependencies 配列を空に設定する場合と、単に空のままにしておく場合の違いは何ですか?useEffect(fn) → レンダリングのたびに実行されます(マウント時および更新のたびに); 空のdependencies配列 useEffect(fn, []) → マウント時に1回のみ実行されます;空でないdependencies useEffect(fn, [a, b]) → マウント時に実行されるほか、aまたはbが変更された際にも実行されます。最も一般的なバグの原因:空の配列を指定しながら、エフェクト内でstateやpropsを使用してしまうこと(クロージャートラップ)。📖 まとめ
- 2つの鉄則:フックは最上位レベルでのみ呼び出し、また関数コンポーネントまたはカスタムフック内でのみ呼び出すこと
- クロージャの注意点:
useEffectまたはuseCallback内でアクセスされる変数は、dependencies 配列で宣言するか、関数を使用して更新する必要があります。 - 無限ループ:依存関係配列が正しいか、参照型が安定しているか、およびエフェクト内で依存関係が変更されていないかを確認する
- 機能の更新:
setCount(prev => prev + 1)クロージャーに関する落とし穴と一括更新の問題を修正 eslint-plugin-react-hooksをインストールしてください。手動で無効にしないでください。- React DevToolsのプロファイラーを使用して、パフォーマンスのボトルネックを特定する
📝 練習問題
-
基本問題(難易度:⭐):以下のコードにあるバグを見つけて修正してください:
JSXfunction Timer() { const [time, setTime] = useState(0) useEffect(() => { const id = setInterval(() => { setTime(time + 1) }, 1000) return () => clearInterval(id) }, []) return <p>{time}s</p> } -
上級問題(難易度 ⭐⭐):以下のコードに含まれるバグを見つけ、修正してください(少なくとも3つ):
JSXfunction SearchPage() { const [query, setQuery] = useState('') const [results, setResults] = useState([]) useEffect(async () => { const data = await fetch('/api/search?q=' + query) setResults(data) }, []) return <div> <input value={query} onChange={e => setQuery(e.target.value)} /> {results.map(r => <p key={r.id}>{r.name}</p>)} </div> } -
課題(難易度:⭐⭐⭐):コールバック関数を受け取り、常に参照安定な関数を返す一方で、呼び出された際に最新のコールバックを実行する
useStableCallbackフックを実装してください(クロージャートラップに対する究極の解決策)。



