404 Not Found

404 Not Found


nginx

フックのルールとよくある落とし穴

1. 学習内容



2. デバッグの悪夢の物語

(1) 課題:たった1つのuseEffectのデバッグに3時間もかかってしまった

アリスはカウントダウンコンポーネントを作成しました:

(1) ▶ サンプル:Hooks Rule デモ

JSX
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) 修正

JSX
// ✅ 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:フックは最上位レベルでのみ呼び出す

ループ、条件文、またはネストされた関数内では、フックを呼び出さないでください:

JSX
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: フックは関数コンポーネントまたはカスタムフック内でのみ呼び出す

JSX
// ❌ 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つのルールがそれほど重要なのでしょうか?

100%
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 のバグ。

JSX
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
}

発生時useEffectuseCallback、またはuseMemoで、dependencies配列に宣言されていない値が読み込まれました。

クロージャーの落とし穴となるシナリオ 症状 解決策
setInterval の状態の読み取り タイマーの値がまったく変わらない 機能の更新 setState(prev => ...)
useCallback での状態の読み取り コールバック内で古い値を使用 依存関係配列への状態の追加、または ref の使用
イベントハンドラの実行が遅延している 非同期コールバック内の state の値が古くなっている useRef を使用して最新の値を保存してください
useEffect の依存関係が不足しています エフェクト内で使用されている変数が更新されていません 依存関係配列を完成させてください
非同期リクエスト後にレースコンディションが発生する 高速切り替え時に、古いリクエストが新しい結果を上書きしてしまう ignore フラグまたは AbortController を使用する

トラブルシューティングのヒント: コールバック内で読み込まれる変数はすべて、dependencies 配列で宣言されている必要があります。あるいは、関数型の更新を使用してください。



5. 落とし穴 2:useEffect の無限ループ

(1) 理由 1:依存関係の配列がない

JSX
// ❌ 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:依存関係配列内の参照型

JSX
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 内の依存関係を変更する

JSX
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 を使用する
JSX
// ❌ 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 による非同期更新

JSX
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 を使用すること

JSX
// ❌ 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の一般的なバグ診断表

JSX
// ============================================
// 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 プロファイラー

JSX
// 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) なぜレンダリングしたのか

BASH
npm install @welldone-software/why-did-you-render
JSX
// 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 を使ったデバッグ

JSX
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

JSX
// ============================================
// 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 を使用した一括更新における落とし穴と対処法

JSX
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 を使用した非同期リクエストにおけるレースコンディション

JSX
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 を使用する

JSX
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>
  )
}
▶ 試してみよう

❓ よくある質問

Q setState を呼び出した後、ページが更新されないことがあるのはなぜですか?
A 考えられる理由:① state オブジェクトを直接変更した(新しい参照を作成せずに); ② 状態の値が変わらなかった(ReactはObject.isを使って値を比較するため、値が同一の場合はレンダリングがトリガーされない); ③ useEffectのdependencies配列に、更新された変数を宣言し忘れた; ④ 非同期コールバック内で古いクロージャの値を読み取ってしまった。
Q useEffect で、dependencies 配列を空に設定する場合と、単に空のままにしておく場合の違いは何ですか?
A dependencies 配列を空のままにする場合 useEffect(fn) → レンダリングのたびに実行されます(マウント時および更新のたびに); 空のdependencies配列 useEffect(fn, []) → マウント時に1回のみ実行されます;空でないdependencies useEffect(fn, [a, b]) → マウント時に実行されるほか、aまたはbが変更された際にも実行されます。最も一般的なバグの原因:空の配列を指定しながら、エフェクト内でstateやpropsを使用してしまうこと(クロージャートラップ)。

📖 まとめ


📝 練習問題

  1. 基本問題(難易度:⭐):以下のコードにあるバグを見つけて修正してください:

    JSX
    function Timer() {
      const [time, setTime] = useState(0)
      useEffect(() => {
        const id = setInterval(() => {
          setTime(time + 1)
        }, 1000)
        return () => clearInterval(id)
      }, [])
      return <p>{time}s</p>
    }
    
  2. 上級問題(難易度 ⭐⭐):以下のコードに含まれるバグを見つけ、修正してください(少なくとも3つ):

    JSX
    function 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>
    }
    
  3. 課題(難易度:⭐⭐⭐):コールバック関数を受け取り、常に参照安定な関数を返す一方で、呼び出された際に最新のコールバックを実行する useStableCallback フックを実装してください(クロージャートラップに対する究極の解決策)。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%