useEffect: Side Effects
React components are supposed to be pure — given the same props and state, they always return the same JSX, with no observable side effects during rendering. But real applications need to interact with the world outside React: fetch data from a server, subscribe to events, update the browser tab title, or start a timer. These operations are called side effects, and useEffect is how you run them safely inside function components.
What Is a Side Effect?
A side effect is anything that affects something outside the scope of the current function call — anything that React does not control directly. Common examples include:
Data fetching — calling
fetch()or a GraphQL querySubscriptions — adding event listeners to
window, a WebSocket, or an observableDOM manipulation — focusing an input, measuring an element, or calling a third-party DOM library
Timers —
setTimeoutandsetIntervalLogging — sending analytics events
Syncing with external systems — updating localStorage, syncing with a server, writing to a ref
The mental model: effects synchronize React state to external systems. When state changes, React re-renders, and your effect keeps whatever external system in sync with the new state.
The useEffect Signature
useEffect( setup, // function to run after render deps? // optional dependency array )
setup— a function containing your side-effect logic. It may optionally return a cleanup function.deps— an optional array of reactive values the effect depends on. React uses this to decide when to re-run the effect.
When Effects Run
Effects do not run during rendering. They run after React has committed changes to the DOM — the browser has already painted the new UI. This ordering is intentional: if your effect mutates the DOM (e.g., measuring an element), React needs the DOM to be up to date first.
Phase | What happens |
|---|---|
Render | React calls your component function and computes the new JSX |
Commit | React updates the DOM to match the new JSX |
Paint | Browser paints pixels on screen |
Effect |
|
Example 1: Syncing document.title with State
The simplest effect: keep the browser tab title in sync with a counter. Without useEffect you would have to remember to update the title every time the counter changes. With useEffect, React handles the synchronization for you.
import { useState, useEffect } from 'react'
function Counter() {
const [count, setCount] = useState(0)
// This effect runs after every render where 'count' changed.
useEffect(() => {
document.title = `You clicked ${count} times`
}, [count])
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
)
}On every render where count has changed, React updates document.title to reflect the new value. The [count] dependency array tells React: "only re-run this effect when count changes."
Example 2: Subscribing to window resize
Event listeners are a classic side effect. You attach a listener when the component mounts and must remove it when the component unmounts — otherwise you create a memory leak (the listener keeps a reference to your component even after it is gone).
import { useState, useEffect } from 'react'
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth)
useEffect(() => {
function handleResize() {
setWidth(window.innerWidth)
}
window.addEventListener('resize', handleResize)
// Cleanup: remove the listener when the component unmounts
return () => {
window.removeEventListener('resize', handleResize)
}
}, []) // [] = run once on mount, clean up on unmount
return <p>Window width: {width}px</p>
}The function returned from setup is the cleanup function. React calls it before running the effect again and when the component unmounts. More on cleanup in the next lesson.
Example 3: Fetching Data on Mount
Fetching data when a component first renders is one of the most common uses of useEffect. The key is to handle the case where the component unmounts before the fetch completes — otherwise you will try to update state on an unmounted component.
import { useState, useEffect } from 'react'
function UserProfile({ userId }) {
const [user, setUser] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
// AbortController lets us cancel the fetch if the component unmounts
const controller = new AbortController()
setLoading(true)
setError(null)
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error('Failed to fetch user')
return res.json()
})
.then(data => {
setUser(data)
setLoading(false)
})
.catch(err => {
// Ignore errors caused by our own abort
if (err.name !== 'AbortError') {
setError(err.message)
setLoading(false)
}
})
// Cleanup: abort the in-flight request if userId changes or component unmounts
return () => controller.abort()
}, [userId]) // Re-run when userId changes
if (loading) return <p>Loading...</p>
if (error) return <p>Error: {error}</p>
return <h1>{user.name}</h1>
}The Cleanup Function
Every effect can optionally return a cleanup function. React calls the cleanup:
Before running the effect again (when deps changed and the effect is about to re-run)
When the component unmounts
useEffect(() => {
// setup — runs after render
const subscription = subscribeToData(id, handleData)
return () => {
// cleanup — runs before next effect OR on unmount
subscription.unsubscribe()
}
}, [id])The Mental Model
Think of useEffect not as "run code after render" but as "synchronize an external system with React state":
document.titleshould equalcount→ effect keeps it in syncThe resize listener should exist while this component is on screen → effect attaches on mount, cleans up on unmount
The user data should match
userId→ effect fetches wheneveruserIdchanges
Rules of Hooks (apply to useEffect too)
Call
useEffectat the top level of your component, not inside loops, conditions, or nested functionsCall
useEffectonly in function components or custom hooks