React Hooks Deep Dive | useEffect· useMemo
이 글의 핵심
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.
?�주 묻는 질문 (FAQ)
Q. ???�용???�무?�서 ?�제 ?�나??
A. Master React hooks with real-world patterns. Covers useEffect cleanup, useMemo vs useCallback, useReducer, custom hooks,???�무?�서????본문???�제?� ?�택 가?�드�?참고???�용?�면 ?�니??
Q. ?�행?�로 ?�으�?좋�? 글?�?
A. �?글 ?�단???�전 글 ?�는 관??글 링크�??�라가�??�서?��?배울 ???�습?�다. C++ ?�리�?목차?�서 ?�체 ?�름???�인?????�습?�다.
Q. ??깊이 공�??�려�?
A. cppreference?� ?�당 ?�이브러�?공식 문서�?참고?�세?? 글 말�???참고 ?�료 링크???�용?�면 좋습?�다.
같이 보면 좋�? 글 (?��? 링크)
??주제?� ?�결?�는 ?�른 글?�니??
- [React 18 Deep Dive | Concurrent Features· Suspense](/en/blog/react-18-deep-dive/
- [TypeScript 5 Complete Guide | Decorators· satisfies](/en/blog/typescript-5-complete-guide/
??글?�서 ?�루???�워??(관??검?�어)
React, Hooks, JavaScript, TypeScript, Frontend, Performance ?�으�?검?�하?�면 ??글???��????�니??