useEffect Hook
Learn how useEffect handles side effects in React, how the dependency array controls when effects run, and why cleanup functions matter.
Introduction
Rendering a component and returning JSX is only part of what a real application needs to do. Sooner or later, you need to fetch data from a server, set up a timer, subscribe to an external event, or manually adjust something outside of React's rendering — these are all examples of side effects, and useEffect is the Hook designed specifically to handle them.
This lesson covers what a side effect actually is, the basic useEffect syntax, the crucial role of the dependency array in controlling when an effect re-runs, cleanup functions for anything that needs to be undone, and a very common bug: effects that accidentally cause infinite re-render loops.
- What counts as a "side effect" in a React component.
- The basic syntax of useEffect.
- The three distinct behaviors controlled by the dependency array.
- Why and how to return a cleanup function from an effect.
- How missing or incorrect dependencies can cause infinite loops.
What Is a Side Effect?
A side effect is anything a component does that reaches outside of the pure "take props and state, return JSX" process — talking to a server, reading or writing to localStorage, manually touching the DOM, starting a timer, or subscribing to an external data source. React's rendering itself should stay pure and predictable, so any of this "impure" work is moved into useEffect, which runs after React has updated the DOM.
Data Fetching
Calling an API when a component mounts or when a piece of data it depends on changes.
Timers & Intervals
Starting a setTimeout or setInterval, and clearing it later.
Subscriptions
Listening to a WebSocket, an event emitter, or the browser's window/document events.
Manual DOM Access
Directly reading or writing something outside React's normal rendering, like document.title.
Basic useEffect Syntax
useEffect takes a function (the effect itself) as its first argument. React runs that function after the render is committed to the screen, meaning the DOM has already been updated by the time your effect code runs.
import { useState, useEffect } from 'react';
function DocumentTitle() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`;
});
return (
<button onClick={() => setCount(count + 1)}>
Click me ({count})
</button>
);
}You clicked 0 times → (after clicking) → You clicked 1 timesHere, no second argument was passed, so this effect runs after every single render — useful for demonstration, but rarely what you actually want in a real app, which is why the dependency array exists.
The Dependency Array
The optional second argument to useEffect is an array of dependencies. It tells React exactly when the effect needs to re-run, and controls one of three distinct behaviors.
| Dependency Array | Behavior |
|---|---|
| No array at all | Effect runs after every render (initial and every update). |
| Empty array [] | Effect runs only once, right after the component first mounts. |
| [dep1, dep2, ...] | Effect runs after the initial mount, and again any time one of the listed values changes. |
useEffect(() => {
console.log('Runs after EVERY render');
});
useEffect(() => {
console.log('Runs ONCE, on mount only');
}, []);
useEffect(() => {
console.log(`Runs on mount AND whenever userId changes`);
}, [userId]);A very common real-world pattern is useEffect(() => { fetchUser(userId).then(setUser); }, [userId]) — the effect re-fetches automatically whenever userId changes, and does not run needlessly on unrelated re-renders.
Cleanup Functions
Some side effects need to be undone before the component unmounts, or before the effect runs again — clearing a timer, removing an event listener, or closing a subscription. You do this by returning a function from inside your effect; React calls that returned function automatically at the right time.
import { useState, useEffect } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
// Cleanup: runs before the effect re-runs, and when the component unmounts
return () => clearInterval(intervalId);
}, []);
return <p>Elapsed: {seconds}s</p>;
}Without this cleanup function, the interval would keep running even after the Timer component was removed from the screen — a classic memory leak. React calls the cleanup function right before running the effect again (if dependencies changed) and one final time when the component unmounts.
Effects That Almost Always Need Cleanup
- setInterval / setTimeout → clear with clearInterval / clearTimeout.
- addEventListener → remove with removeEventListener.
- A subscription to a WebSocket or observable → unsubscribe.
- Any external resource opened by the effect that is not automatically released.
Avoiding Infinite Loops
One of the most common useEffect bugs happens when an effect updates a piece of state that is also listed in its own dependency array — without a condition to stop it. Each state update triggers a re-render, the re-render re-runs the effect, and the effect updates state again, forever.
function BuggyCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // updates count...
}, [count]); // ...which re-triggers this same effect, forever
return <p>{count}</p>;
}function FixedCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
// Only run once, and use a functional update if it must reference count
const id = setInterval(() => setCount(prev => prev + 1), 1000);
return () => clearInterval(id);
}, []); // empty array — runs once, no unnecessary re-triggering
return <p>{count}</p>;
}If an effect sets a piece of state, and that same piece of state is also in the dependency array, double-check there is a real reason for it (and usually a condition to stop updating) — otherwise you likely have an infinite loop.
Common Mistakes
- Omitting a value used inside the effect from the dependency array, causing the effect to use a stale value.
- Forgetting to return a cleanup function for timers, listeners, or subscriptions, causing memory leaks.
- Updating state inside an effect that also depends on that same state, without any guard condition — a classic infinite loop.
- Using an empty dependency array [] just to "make a warning go away" without considering whether the effect actually needs to re-run on some value change.
Best Practices
- Include every value from component scope that the effect actually uses in the dependency array.
- Always return a cleanup function for effects that start timers, add listeners, or open subscriptions.
- Keep each useEffect focused on a single concern rather than combining unrelated logic into one effect.
- Let your linter (eslint-plugin-react-hooks) guide you — its exhaustive-deps rule flags missing dependencies.
- Prefer functional state updates (prev => ...) inside effects to avoid needing the state value itself as a dependency.
Frequently Asked Questions
Does useEffect run before or after the DOM updates?
After. React updates the actual DOM first, then runs your effect. This is why useEffect is safe for things like reading layout or interacting with elements that just rendered.
What happens if I do not pass a dependency array at all?
The effect runs after every render — the initial one and every subsequent update. This is rarely what you want for anything beyond debugging or very simple cases.
Can I use an async function directly as the effect callback?
No, the function passed to useEffect cannot itself be async, since useEffect expects its return value to be either nothing or a cleanup function, not a Promise. Instead, define an async function inside the effect and call it immediately.
Why does my effect run twice in development?
In development, React (in Strict Mode) intentionally mounts, cleans up, and re-mounts components once to help you catch missing cleanup functions. This does not happen in production builds.
How many useEffect calls can one component have?
As many as needed. It is usually cleaner to use multiple useEffect calls, each handling one unrelated concern, rather than combining everything into a single large effect.
Key Takeaways
- Side effects are anything reaching outside pure rendering: fetching data, timers, subscriptions, manual DOM access.
- useEffect runs its callback after the DOM has been updated for the current render.
- The dependency array controls timing: no array (every render), [] (mount only), or [deps] (mount plus whenever a dependency changes).
- Returning a function from an effect defines its cleanup, run before re-running the effect and on unmount.
- Updating state inside an effect that also lists that state as a dependency, without a guard, causes an infinite loop.
Summary
useEffect connects your components to the outside world — servers, timers, subscriptions, and the browser itself — while keeping rendering logic itself pure. Getting the dependency array right, and always cleaning up after effects that need it, are the two habits that separate solid useEffect code from a source of subtle bugs.
Next, you will learn useRef — a Hook for holding mutable values and referencing DOM elements directly, without triggering re-renders.
- You understand what counts as a side effect in React.
- You can control when an effect runs using the dependency array.
- You know how and when to return a cleanup function.
- You can recognize and fix an infinite effect loop.