React Hooks Deep Dive | useEffect, useMemo, useCallback, and Custom Hooks

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

NeedHook
Simple stateuseState
Complex state with actionsuseReducer
Side effects / subscriptionsuseEffect
Expensive computationuseMemo
Stable callback referenceuseCallback
DOM access / mutable valueuseRef
Shared logic across componentsCustom hook
Reading contextuseContext
Server-side data (React 19)use()

Key Takeaways

  1. Always clean up useEffect — remove listeners, cancel fetches, clear timers
  2. Stale closures — use functional updater prev => prev + 1, or add to deps, or use useRef
  3. Objects in deps — use useMemo to stabilize references or use primitive values
  4. useCallback + memo — only memoize components that re-render expensively and frequently
  5. Custom hooks — extract any useEffect + useState pattern used in more than one component
  6. 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.