React Hooks Deep Dive | useEffect, useMemo, useCallback, and Custom Hooks
이 글의 핵심
React hooks are simple to start but full of subtle bugs — stale closures, missing dependencies, memory leaks from missing cleanup. This guide covers the patterns that matter in production React apps.
Why Hooks Are Tricky
Hooks look simple — useState, useEffect — but produce subtle bugs:
- Stale closures capturing old state
- Infinite effect loops
- Memory leaks from missing cleanup
- Expensive computations on every render
This guide covers the patterns that prevent these bugs in production.
1. useEffect — The Full Picture
useEffect(() => {
// Side effect runs AFTER render
const handler = () => console.log('scrolled')
window.addEventListener('scroll', handler)
// Cleanup runs BEFORE next effect and on unmount
return () => {
window.removeEventListener('scroll', handler)
}
}, []) // [] = run once on mount / cleanup on unmount
Dependency array rules
// ❌ Missing dependency — stale closure bug
useEffect(() => {
fetchUser(userId) // if userId changes, effect doesn't re-run
}, [])
// ✅ Correct
useEffect(() => {
fetchUser(userId)
}, [userId])
// ❌ Object in dependencies — new reference every render = infinite loop
useEffect(() => {
fetchData(options)
}, [options]) // { page: 1 } !== { page: 1 }
// ✅ Primitive values in dependencies
useEffect(() => {
fetchData({ page, limit })
}, [page, limit])
Common cleanup patterns
// Fetch with abort controller (prevents state update on unmounted component)
useEffect(() => {
const controller = new AbortController()
fetch(`/api/users/${id}`, { signal: controller.signal })
.then(r => r.json())
.then(data => setUser(data))
.catch(err => {
if (err.name !== 'AbortError') setError(err)
})
return () => controller.abort()
}, [id])
// WebSocket
useEffect(() => {
const ws = new WebSocket('wss://api.example.com/ws')
ws.onmessage = (e) => setMessages(m => [...m, JSON.parse(e.data)])
return () => ws.close()
}, [])
// Interval
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000)
return () => clearInterval(id)
}, [])
2. Stale Closures — The #1 Hook Bug
// ❌ Stale closure: count is always 0 inside the callback
function Counter() {
const [count, setCount] = useState(0)
useEffect(() => {
const id = setInterval(() => {
console.log(count) // always 0!
setCount(count + 1) // always sets to 1
}, 1000)
return () => clearInterval(id)
}, []) // count not in deps → stale
return <div>{count}</div>
}
// ✅ Fix 1: Functional updater (doesn't need current value in closure)
useEffect(() => {
const id = setInterval(() => {
setCount(prev => prev + 1) // always correct
}, 1000)
return () => clearInterval(id)
}, [])
// ✅ Fix 2: Add to dependencies
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1)
}, 1000)
return () => clearInterval(id)
}, [count]) // re-creates interval when count changes
// ✅ Fix 3: useRef for values you don't want to re-trigger effects
function useLatest<T>(value: T) {
const ref = useRef(value)
useEffect(() => { ref.current = value })
return ref
}
function Timer({ onTick }: { onTick: () => void }) {
const onTickRef = useLatest(onTick)
useEffect(() => {
const id = setInterval(() => onTickRef.current(), 1000)
return () => clearInterval(id)
}, []) // no dependency on onTick, but always calls latest version
}
3. useMemo — When It Helps
// ❌ Unnecessary — computation is cheap
const doubled = useMemo(() => count * 2, [count])
// Just do: const doubled = count * 2
// ✅ Use for expensive computations
const filteredUsers = useMemo(() =>
users.filter(u => u.name.toLowerCase().includes(search.toLowerCase())),
[users, search]
)
// ✅ Stable object reference for useEffect dependency
const queryOptions = useMemo(
() => ({ page, limit, filters }),
[page, limit, filters]
)
useEffect(() => {
fetchData(queryOptions)
}, [queryOptions]) // won't loop — same reference when values unchanged
// ✅ Expensive transformation (1000+ items)
const sortedAndGrouped = useMemo(() => {
return groupBy(
sortBy(products, 'price'),
'category'
)
}, [products])
4. useCallback — Preventing Child Re-renders
// ❌ Passes new function reference every render → child always re-renders
function Parent() {
const [count, setCount] = useState(0)
const handleClick = () => console.log('clicked') // new function each render
return <MemoizedChild onClick={handleClick} />
}
// ✅ Stable reference
function Parent() {
const [count, setCount] = useState(0)
const handleClick = useCallback(() => {
console.log('clicked')
}, []) // stable — same function reference
return <MemoizedChild onClick={handleClick} />
}
const MemoizedChild = memo(({ onClick }) => {
console.log('child rendered')
return <button onClick={onClick}>Click</button>
})
// ✅ useCallback with dependencies
const handleSearch = useCallback((query: string) => {
setPage(1)
setSearch(query)
}, []) // setPage and setSearch are stable (from useState)
// ✅ useCallback for event handlers passed to lists
const handleDelete = useCallback((id: string) => {
setItems(prev => prev.filter(item => item.id !== id))
}, [])
5. useReducer — Complex State Logic
type State = {
status: 'idle' | 'loading' | 'success' | 'error'
data: User[] | null
error: string | null
}
type Action =
| { type: 'FETCH_START' }
| { type: 'FETCH_SUCCESS'; payload: User[] }
| { type: 'FETCH_ERROR'; payload: string }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'FETCH_START':
return { status: 'loading', data: null, error: null }
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload, error: null }
case 'FETCH_ERROR':
return { status: 'error', data: null, error: action.payload }
}
}
function UserList() {
const [state, dispatch] = useReducer(reducer, {
status: 'idle', data: null, error: null
})
useEffect(() => {
dispatch({ type: 'FETCH_START' })
fetchUsers()
.then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
.catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message }))
}, [])
if (state.status === 'loading') return <Spinner />
if (state.status === 'error') return <ErrorMessage message={state.error!} />
return <ul>{state.data?.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
6. Custom Hooks
Extract reusable stateful logic into custom hooks:
// useFetch — data fetching with loading/error state
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const controller = new AbortController()
setLoading(true)
fetch(url, { signal: controller.signal })
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() })
.then(data => { setData(data); setError(null) })
.catch(err => { if (err.name !== 'AbortError') setError(err) })
.finally(() => setLoading(false))
return () => controller.abort()
}, [url])
return { data, loading, error }
}
// useLocalStorage — sync state with localStorage
function useLocalStorage<T>(key: string, defaultValue: T) {
const [value, setValue] = useState<T>(() => {
try {
const item = localStorage.getItem(key)
return item ? JSON.parse(item) : defaultValue
} catch { return defaultValue }
})
const setStoredValue = useCallback((newValue: T | ((prev: T) => T)) => {
setValue(prev => {
const resolved = typeof newValue === 'function'
? (newValue as (prev: T) => T)(prev)
: newValue
localStorage.setItem(key, JSON.stringify(resolved))
return resolved
})
}, [key])
return [value, setStoredValue] as const
}
// useDebounce — debounce a rapidly-changing value
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}
// Usage
function SearchInput() {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 300)
const { data, loading } = useFetch(`/api/search?q=${debouncedQuery}`)
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
{loading && <Spinner />}
{data?.map(item => <div key={item.id}>{item.name}</div>)}
</>
)
}
7. useRef — Beyond DOM Access
// Store previous value
function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T>()
useEffect(() => { ref.current = value })
return ref.current
}
// Interval that can be updated without re-subscribing
function useInterval(callback: () => void, delay: number) {
const callbackRef = useRef(callback)
useEffect(() => { callbackRef.current = callback })
useEffect(() => {
const id = setInterval(() => callbackRef.current(), delay)
return () => clearInterval(id)
}, [delay])
}
// Track mounted state (prevent setState on unmounted)
function useIsMounted() {
const mounted = useRef(false)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false }
}, [])
return mounted
}
8. React 19: use() Hook
// React 19: read promises directly in components
import { use, Suspense } from 'react'
// Create promise outside component (stable reference)
const userPromise = fetchUser('123')
function UserProfile() {
const user = use(userPromise) // suspends until resolved
return <h1>{user.name}</h1>
}
// Wrap in Suspense
<Suspense fallback={<Spinner />}>
<UserProfile />
</Suspense>
Hooks Decision Guide
| Need | Hook |
|---|---|
| Simple state | useState |
| Complex state with actions | useReducer |
| Side effects / subscriptions | useEffect |
| Expensive computation | useMemo |
| Stable callback reference | useCallback |
| DOM access / mutable value | useRef |
| Shared logic across components | Custom hook |
| Reading context | useContext |
| Server-side data (React 19) | use() |
Key Takeaways
- Always clean up
useEffect— remove listeners, cancel fetches, clear timers - Stale closures — use functional updater
prev => prev + 1, or add to deps, or useuseRef - Objects in deps — use
useMemoto stabilize references or use primitive values useCallback+memo— only memoize components that re-render expensively and frequently- Custom hooks — extract any
useEffect+useStatepattern used in more than one component useReducer— prefer for state with multiple related values or complex transitions
The biggest source of React hook bugs: treating hooks like lifecycle methods from class components. Hooks represent synchronization with external values — your effects should be idempotent, cleanup should be complete, and dependencies should be exhaustive.