React

React Hooks: useEffect, useMemo & Custom Hooks

P

Prasanna Joshi

Author

November 12, 2024
18 min read

The Philosophy of Hooks

Hooks allow you to reuse stateful logic without changing your component hierarchy. But they come with rules. Released in React 16.8, hooks revolutionized how we write React components by allowing functional components to have state and lifecycle features previously only available in class components.

The Philosophy of Hooks

Hooks allow you to reuse stateful logic without changing your component hierarchy. But they come with rules. Released in React 16.8, hooks revolutionized how we write React components by allowing functional components to have state and lifecycle features previously only available in class components.

useState - Managing Component State

The most basic hook, but understanding its nuances is crucial for writing correct React code:

// Basic usage
const [count, setCount] = useState(0)

// With more complex state
interface User {
  name: string
  email: string
  role: 'admin' | 'user'
}

const [user, setUser] = useState<User | null>(null)

// Lazy initialization - for expensive computations
const [state, setState] = useState(() => {
  // This function only runs once on mount, not on every render
  return expensiveInitialization()
})

// Functional updates to avoid stale state
setCount(prev => prev + 1) // Always has current value

The key insight: setState is asynchronous. If you need state to update synchronously, you're approaching the problem wrong.

The Dependency Array - The Most Common Source of Bugs

The most common source of bugs in React is lying to React about dependencies. If you use a variable inside useEffect, it MUST be in the dependency array. This is not optional—it's a critical rule.

// WRONG - will cause stale closures
useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1) // count is always the same value!
  }, 1000)
}, []) // Missing 'count' in dependencies

// CORRECT - updates properly
useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1)
  }, 1000)
  
  return () => clearInterval(timer) // Always cleanup!
}, []) // No dependencies needed with functional updates

Understanding Closures in useEffect

Closures are a common source of confusion. Each time your component renders, it creates a new function scope. If you reference a variable from outside that scope, you're creating a closure.

function UserProfile({ userId }) {
  useEffect(() => {
    // This closure captures userId at render time
    fetchUser(userId)
  }, [userId]) // Must include userId as dependency
}

// Every change to userId triggers a new effect
// userId is "stale" if not in dependency array

useEffect - Managing Side Effects

useEffect is where side effects happen in functional components. Understanding its timing and cleanup is critical.

Effect Timing

function Component() {
  const [data, setData] = useState(null)
  
  // Runs AFTER every render, including after mount
  useEffect(() => {
    console.log('Effect ran')
  })
  
  // Runs AFTER render, but only when dependencies change
  useEffect(() => {
    loadData()
  }, [])
  
  // Runs only once on mount
  useEffect(() => {
    const subscription = subscribeToUpdates()
    
    // Cleanup runs before component unmounts
    return () => {
      subscription.unsubscribe()
    }
  }, [])
}

Cleanup Functions

Always cleanup subscriptions, timers, and event listeners to prevent memory leaks:

// BAD - memory leak, timer keeps running
useEffect(() => {
  const timer = setInterval(() => {
    console.log('tick')
  }, 1000)
})

// GOOD - cleanup timer
useEffect(() => {
  const timer = setInterval(() => {
    console.log('tick')
  }, 1000)
  
  return () => clearInterval(timer)
}, [])

// Unsubscribing from events
useEffect(() => {
  const handleResize = () => {
    console.log(window.innerWidth)
  }
  
  window.addEventListener('resize', handleResize)
  
  return () => {
    window.removeEventListener('resize', handleResize)
  }
}, [])

useMemo and useCallback - Performance Optimization

Both are performance optimizations, but they solve different problems. Understanding when to use each is crucial.

useMemo - Memoizing Computed Values

Use when you have expensive calculations that shouldn't be recomputed on every render:

// Without memoization - recalculates every render
function UserList({ users, filterTerm }) {
  const filteredUsers = users.filter(u => 
    u.name.includes(filterTerm)
  ) // Expensive if users array is large
  
  return <UserListDisplay users={filteredUsers} />
}

// With memoization
function UserList({ users, filterTerm }) {
  const filteredUsers = useMemo(
    () => users.filter(u => u.name.includes(filterTerm)),
    [users, filterTerm]
  )
  
  return <UserListDisplay users={filteredUsers} />
}

Important: Only use useMemo if you've actually measured a performance problem. It has overhead too.

useCallback - Memoizing Function Definitions

Use when passing functions to memoized child components, or when the function is in a dependency array:

// Child component is optimized with React.memo
const MemoizedButton = React.memo(({ onClick, label }) => {
  console.log('Button rendered')
  return <button onClick={onClick}>{label}</button>
})

// Without useCallback - new function on every render
function Parent() {
  const handleClick = () => {
    console.log('clicked')
  }
  
  return <MemoizedButton onClick={handleClick} label="Click me" />
  // MemoizedButton re-renders every time because onClick changes
}

// With useCallback - same function reference
function Parent() {
  const handleClick = useCallback(() => {
    console.log('clicked')
  }, [])
  
  return <MemoizedButton onClick={handleClick} label="Click me" />
  // MemoizedButton only re-renders if label changes
}

When NOT to Use These Hooks

// DON'T memoize primitive values
const x = useMemo(() => 5, []) // Pointless

// DON'T memoize without dependencies
const value = useMemo(() => calculateSomething(), []) // Can still change

// DON'T useCallback for every function
const handleClick = useCallback(() => {}, []) // Only if it's a dependency

// GOOD - only when there's a performance benefit
const expensiveValue = useMemo(() => {
  return items.filter(...).map(...).reduce(...)
}, [items])

Building Custom Hooks

Custom hooks let you extract component logic into reusable functions. A custom hook is a JavaScript function whose name starts with "use" and may call other hooks.

Simple Custom Hook - useWindowSize

// Custom hook - useWindowSize
function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 })
  
  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight })
    }
    
    window.addEventListener('resize', handleResize)
    handleResize() // Call once to set initial size
    
    return () => window.removeEventListener('resize', handleResize)
  }, [])
  
  return size
}

// Usage
function MyComponent() {
  const { width, height } = useWindowSize()
  return <div>Window is {width}x{height}</div>
}

Advanced Custom Hook - useFetch

A real-world custom hook that handles loading, error, and data states:

interface UseFetchResult<T> {
  data: T | null
  loading: boolean
  error: Error | null
  retry: () => void
}

function useFetch<T>(
  url: string,
  options?: RequestInit
): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<Error | null>(null)
  
  const fetchData = useCallback(async () => {
    setLoading(true)
    setError(null)
    try {
      const response = await fetch(url, options)
      if (!response.ok) throw new Error('Failed to fetch')
      const json = await response.json()
      setData(json)
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Unknown error'))
    } finally {
      setLoading(false)
    }
  }, [url, options])
  
  useEffect(() => {
    fetchData()
  }, [fetchData])
  
  return { data, loading, error, retry: fetchData }
}

// Usage
function UserList() {
  const { data: users, loading, error } = useFetch<User[]>('/api/users')
  
  if (loading) return <div>Loading...</div>
  if (error) return <div>Error: {error.message}</div>
  
  return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}

Common Pitfalls and How to Avoid Them

1. Infinite Loops with useEffect

If you update state in useEffect without proper dependencies, you'll create an infinite loop:

// INFINITE LOOP - don't do this!
useEffect(() => {
  setData(data + 1) // Causes re-render, which calls effect again
}) // No dependency array = runs after EVERY render

// CORRECT - run only once on mount
useEffect(() => {
  fetchData()
}, []) // Empty dependency array

// CORRECT - run when specific deps change
useEffect(() => {
  loadUserData(userId)
}, [userId]) // Only runs when userId changes

2. Stale Closures

Functions defined in useEffect capture values from the current render. If you need current values, use functional setState:

// WRONG - stale count
const [count, setCount] = useState(0)

useEffect(() => {
  const timer = setInterval(() => {
    setCount(count + 1) // count is always 0
  }, 1000)
}, [])

// CORRECT - use functional updates
useEffect(() => {
  const timer = setInterval(() => {
    setCount(prev => prev + 1) // prev is always current
  }, 1000)
  
  return () => clearInterval(timer)
}, [])

3. Missing Cleanup

Forgetting to cleanup can cause memory leaks and bugs:

// WRONG - listener stays attached
useEffect(() => {
  window.addEventListener('scroll', handleScroll)
  // No cleanup!
})

// CORRECT - remove listener on cleanup
useEffect(() => {
  window.addEventListener('scroll', handleScroll)
  return () => window.removeEventListener('scroll', handleScroll)
}, [])

useReducer for Complex State Logic

When state logic becomes complex with multiple sub-values, useReducer is more appropriate than multiple useState calls:

interface State {
  count: number
  error: string | null
  loading: boolean
}

type Action = 
  | { type: 'INCREMENT' }
  | { type: 'DECREMENT' }
  | { type: 'SET_ERROR'; payload: string }
  | { type: 'SET_LOADING'; payload: boolean }

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 }
    case 'DECREMENT':
      return { ...state, count: state.count - 1 }
    case 'SET_ERROR':
      return { ...state, error: action.payload }
    case 'SET_LOADING':
      return { ...state, loading: action.payload }
    default:
      return state
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, {
    count: 0,
    error: null,
    loading: false,
  })
  
  return (
    <div>
      Count: {state.count}
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
    </div>
  )
}

Context + Hooks - Global State Management

Combine useContext with useReducer for lightweight global state management without Redux:

interface ThemeContextType {
  theme: 'light' | 'dark'
  toggleTheme: () => void
}

const ThemeContext = React.createContext<ThemeContextType | undefined>(undefined)

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light')
  
  const toggleTheme = useCallback(() => {
    setTheme(prev => prev === 'light' ? 'dark' : 'light')
  }, [])
  
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  )
}

function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider')
  }
  return context
}

// Usage
function App() {
  const { theme, toggleTheme } = useTheme()
  return (
    <div style={{ background: theme === 'light' ? 'white' : 'black' }}>
      <button onClick={toggleTheme}>Toggle Theme</button>
    </div>
  )
}

Performance Optimization Best Practices

  • Profile First: Use React DevTools Profiler to identify actual performance problems before optimizing.
  • Don't Over-Memoize: Memoization has a cost. Only use it when you've identified a real performance issue with measurements.
  • Optimize Dependencies: Ensure dependency arrays only include values that actually matter for correctness.
  • Use Lazy Initialization: Pass a function to useState for expensive initial state calculations.
  • Avoid Creating Objects/Functions in Render: Create stable references using useMemo/useCallback.
  • Split State Logically: Keep related state together, separate unrelated state to avoid unnecessary re-renders.

Advanced Patterns

useEffect with Async Operations

// DON'T do this - useEffect can't be async
useEffect(async () => {
  const data = await fetchData()
}, [])

// DO this - create async function inside
useEffect(() => {
  let isMounted = true
  
  async function loadData() {
    const data = await fetchData()
    if (isMounted) {
      setData(data)
    }
  }
  
  loadData()
  
  return () => {
    isMounted = false
  }
}, [])

Debouncing with Custom Hook

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value)
  
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)
    
    return () => clearTimeout(handler)
  }, [value, delay])
  
  return debouncedValue
}

// Usage - debounce search input
function SearchUsers() {
  const [searchTerm, setSearchTerm] = useState('')
  const debouncedTerm = useDebounce(searchTerm, 500)
  
  useEffect(() => {
    if (debouncedTerm) {
      searchAPI(debouncedTerm)
    }
  }, [debouncedTerm])
}

Hooks Rules

These rules are enforced by the ESLint plugin eslint-plugin-react-hooks:

  • Only call hooks at the top level: Don't call hooks inside loops, conditions, or nested functions.
  • Only call hooks from React functions: Call hooks from React components or custom hooks only.
  • Use the ESLint plugin: It will catch violations of these rules automatically.
// WRONG - calling hook conditionally
function MyComponent({ shouldFetch }) {
  if (shouldFetch) {
    const data = useFetch('/api/data') // Hook in condition!
  }
}

// CORRECT - always call, conditional logic inside
function MyComponent({ shouldFetch }) {
  const data = useFetch(shouldFetch ? '/api/data' : null)
}

Testing Hooks

Use the @testing-library/react library's renderHook to test custom hooks:

import { renderHook, act } from '@testing-library/react'
import { useWindowSize } from './useWindowSize'

describe('useWindowSize', () => {
  it('returns window dimensions', () => {
    const { result } = renderHook(() => useWindowSize())
    
    expect(result.current.width).toBeGreaterThan(0)
    expect(result.current.height).toBeGreaterThan(0)
  })
  
  it('updates on window resize', () => {
    const { result } = renderHook(() => useWindowSize())
    
    act(() => {
      window.dispatchEvent(new Event('resize'))
    })
    
    // Assert the update
  })
})

Conclusion

Mastering React hooks is essential for modern React development. By understanding useState, useEffect, and custom hooks, along with proper dependency management and cleanup, you'll write more reliable, performant, and maintainable React applications. Start with the fundamentals, practice with real-world examples, and gradually adopt advanced patterns. Remember: the dependency array is your friend. Use it correctly, and 90% of React bugs disappear.

Tags

#React#Frontend#JavaScript#Hooks#Performance#Custom Hooks

Share this article

About the Author

P

Prasanna Joshi

Expert Writer & Developer

Prasanna Joshi is an experienced software engineer and technology writer passionate about helping developers master modern web technologies. With years of professional experience in full-stack development, system design, and best practices, they bring real-world insights to every article.

Specializing in Next.js, TypeScript, Node.js, databases, and web performance optimization. Follow for more in-depth technical content.

Stay Updated

Get the latest articles delivered to your inbox