How to debug React hooks
Debugging React hooks requires understanding hook execution order, dependency arrays, and closure scope. As the creator of CoreUI with 12 years of React development experience, I’ve debugged thousands of hook-related issues in production applications, helping teams identify stale closures, infinite loops, and missing dependencies.
The most effective approach combines React DevTools with strategic console logs and breakpoints.
Use React DevTools
Install React DevTools browser extension, then:
- Open React DevTools (F12 → React tab)
- Select component in the tree
- View hooks in the right panel
- See hook values in real-time
function UserProfile() {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
// DevTools shows:
// State: null
// State: true
return <div>{loading ? 'Loading...' : user?.name}</div>
}
Log Hook Execution
function useDebugValue(value, label) {
console.log(`[${label}]`, value)
return value
}
function Counter() {
const [count, setCount] = useDebugValue(
useState(0),
'count state'
)[0]
useEffect(() => {
console.log('Effect running, count:', count)
return () => {
console.log('Effect cleanup, count:', count)
}
}, [count])
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}
Debug Dependencies
function usePrevious(value) {
const ref = useRef()
useEffect(() => {
ref.current = value
})
return ref.current
}
function MyComponent({ userId, filters }) {
const prevUserId = usePrevious(userId)
const prevFilters = usePrevious(filters)
useEffect(() => {
console.log('Effect triggered')
console.log('userId changed:', prevUserId !== userId)
console.log('filters changed:', prevFilters !== filters)
}, [userId, filters])
return <div>Content</div>
}
Track Re-renders
import { useRef } from 'react'
function useRenderCount() {
const renderCount = useRef(0)
renderCount.current++
console.log(`Render #${renderCount.current}`)
return renderCount.current
}
function useWhyDidYouUpdate(name, props) {
const previousProps = useRef()
useEffect(() => {
if (previousProps.current) {
const allKeys = Object.keys({ ...previousProps.current, ...props })
const changedProps = {}
allKeys.forEach(key => {
if (previousProps.current[key] !== props[key]) {
changedProps[key] = {
from: previousProps.current[key],
to: props[key]
}
}
})
if (Object.keys(changedProps).length > 0) {
console.log('[why-did-you-update]', name, changedProps)
}
}
previousProps.current = props
})
}
function UserCard({ user, theme }) {
useRenderCount()
useWhyDidYouUpdate('UserCard', { user, theme })
return <div>{user.name}</div>
}
Debug Custom Hooks
function useDebugHook(hookName, dependencies) {
const renderCount = useRef(0)
renderCount.current++
console.group(`🪝 ${hookName} - Render #${renderCount.current}`)
console.log('Dependencies:', dependencies)
useEffect(() => {
console.log(`Effect: Running`)
return () => {
console.log(`Effect: Cleanup`)
}
}, dependencies)
console.groupEnd()
}
function useFetchUser(userId) {
useDebugHook('useFetchUser', [userId])
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
console.log('Fetching user:', userId)
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
console.log('User fetched:', data)
setUser(data)
setLoading(false)
})
}, [userId])
return { user, loading }
}
Detect Infinite Loops
function useInfiniteLoopDetector(hookName, maxRenders = 50) {
const renderCount = useRef(0)
const resetTimer = useRef(null)
renderCount.current++
// Reset counter after 1 second of no renders
clearTimeout(resetTimer.current)
resetTimer.current = setTimeout(() => {
renderCount.current = 0
}, 1000)
if (renderCount.current > maxRenders) {
console.error(
`🚨 INFINITE LOOP DETECTED in ${hookName}!`,
`Rendered ${renderCount.current} times in 1 second`
)
}
}
function ProblematicComponent() {
useInfiniteLoopDetector('ProblematicComponent')
const [count, setCount] = useState(0)
useEffect(() => {
// This causes infinite loop!
setCount(count + 1)
}) // Missing dependency array
return <div>Count: {count}</div>
}
Debug useEffect Timing
function useEffectDebugger(effectName, effect, deps) {
const mountTime = useRef(Date.now())
useEffect(() => {
const startTime = Date.now()
console.log(`[${effectName}] Starting effect`)
console.log(`Time since mount: ${startTime - mountTime.current}ms`)
const cleanup = effect()
return () => {
const cleanupTime = Date.now()
console.log(`[${effectName}] Cleanup`)
console.log(`Effect duration: ${cleanupTime - startTime}ms`)
if (cleanup) cleanup()
}
}, deps)
}
function TimedComponent() {
useEffectDebugger('Fetch Data', () => {
console.log('Fetching...')
return () => {
console.log('Cancelling fetch')
}
}, [])
return <div>Content</div>
}
Use React DevTools Profiler
import { Profiler } from 'react'
function onRenderCallback(
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) {
console.log({
component: id,
phase, // "mount" or "update"
renderTime: actualDuration,
totalTime: baseDuration
})
}
function App() {
return (
<Profiler id="App" onRender={onRenderCallback}>
<UserDashboard />
</Profiler>
)
}
Best Practice Note
This is the debugging workflow we use across all CoreUI React projects. React DevTools shows hook state in real-time, while strategic console logs reveal execution order and dependency changes. Always use useEffect dependency arrays correctly, enable ESLint’s exhaustive-deps rule to catch missing dependencies, and use custom debug hooks to track re-renders and identify performance issues.
For production applications, consider using CoreUI’s React Admin Template which includes debugging utilities and performance monitoring.
Related Articles
For related debugging techniques, check out how to debug React with breakpoints and how to fix stale closures in React hooks.



