TUTORIAL

React Hooks Part 2: Side Effects with useEffect

useEffect is how React functional components interact with the outside world — fetching data, setting up subscriptions, and manipulating the DOM. This article explains the dependency array, cleanup, and common pitfalls.


SERIES

React Hooks: A Complete Guide

A four-part deep-dive into React Hooks — from managing state with useState to squeezing performance out of useCallback and useMemo. Each article is standalone yet builds on the previous, giving you both quick reference and progressive mastery.

Table of Contents

What counts as a "side effect"?

Anything that reaches outside the React rendering pipeline is a side effect: fetching data, reading localStorage, subscribing to a WebSocket, or touching the DOM directly. useEffect manages all of it.

JSX
import { useEffect } from 'react'

Basic shape

JSX
useEffect(() => {
  // side effect here
}, [/* dependencies */])

React runs the callback after the component has rendered and the browser has painted. The dependency array controls when it runs again.

The three modes

1. No array — run after every render

JSX
useEffect(() => {
  document.title = `Count: ${count}`
})

Rarely what you want — fires too often.

2. Empty array — run once on mount

JSX
useEffect(() => {
  fetch('/api/user')
    .then(r => r.json())
    .then(setUser)
}, [])

3. Specific dependencies — run when values change

JSX
useEffect(() => {
  fetchResults(query)
}, [query])

Fetching data — a complete example

JSX
import { useState, useEffect } from 'react'

function UserProfile({ userId }) {
  const [user, setUser]       = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError]     = useState(null)

  useEffect(() => {
    let cancelled = false

    setLoading(true)
    fetch(`/api/users/${userId}`)
      .then(r => { if (!r.ok) throw new Error('Not found'); return r.json() })
      .then(data  => { if (!cancelled) setUser(data) })
      .catch(err  => { if (!cancelled) setError(err.message) })
      .finally(() => { if (!cancelled) setLoading(false) })

    return () => { cancelled = true }
  }, [userId])

  if (loading) return <p>Loading…</p>
  if (error)   return <p>Error: {error}</p>
  return <h2>{user?.name}</h2>
}

The cancelled flag guards against setting state on an unmounted component.

The cleanup function

Return a function to run cleanup on unmount — or before the effect re-runs:

JSX
useEffect(() => {
  const ws = new WebSocket('wss://example.com/feed')
  ws.onmessage = e => setPrices(JSON.parse(e.data))

  return () => ws.close()
}, [])

Without cleanup, every re-render that triggers the effect would open a new socket without closing the old one.

Common pitfalls

Missing dependencies

The exhaustive-deps ESLint rule warns when you reference a variable inside useEffect but don't list it. Always listen to it.

Objects in the dependency array

JSX
// Creates an infinite loop — options is a new object every render
useEffect(() => {
  fetchData(options)
}, [options])

Move the object inside the effect or break it into primitive dependencies.

Async callbacks

JSX
// useEffect callback cannot be async
useEffect(async () => { … })

// Define an inner async function instead
useEffect(() => {
  async function load() { … }
  load()
}, [])

Key takeaways

  • useEffect runs after the render, not during it.
  • The dependency array is the most powerful — and most misused — part.
  • Always clean up subscriptions and async work to avoid leaks.
  • Lint with eslint-plugin-react-hooks.

Next up: useRef & useReducer — DOM access and complex state.


Was this article helpful?

w

webencher Editorial

Software engineers and technical writers with 10+ years of combined experience in algorithms, systems design, and web development. Every article is reviewed for accuracy, depth, and practical applicability.

More by this author →