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.
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.
- 1 React Hooks Part 1: Managing State with useState
- 2 React Hooks Part 2: Side Effects with useEffect
- 3 React Hooks Part 3: useRef and useReducer
- 4 React Hooks Part 4: useCallback, useMemo, and Custom Hooks
- 5 React Hooks Part 5: Sharing State with useContext
Table of Contents
- What counts as a "side effect"?
- Basic shape
- The three modes
- 1. No array — run after every render
- 2. Empty array — run once on mount
- 3. Specific dependencies — run when values change
- Fetching data — a complete example
- The cleanup function
- Common pitfalls
- Missing dependencies
- Objects in the dependency array
- Async callbacks
- Key takeaways
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.
import { useEffect } from 'react'
Basic shape
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
useEffect(() => {
document.title = `Count: ${count}`
})
Rarely what you want — fires too often.
2. Empty array — run once on mount
useEffect(() => {
fetch('/api/user')
.then(r => r.json())
.then(setUser)
}, [])
3. Specific dependencies — run when values change
useEffect(() => {
fetchResults(query)
}, [query])
Fetching data — a complete example
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:
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
// 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
// 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.