본문으로 건너뛰기
Previous
Next
React Hooks Deep Dive | useEffect· useMemo

React Hooks Deep Dive | useEffect· useMemo

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

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.


?�주 묻는 질문 (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 ?�으�?검?�하?�면 ??글???��????�니??