
How React's useEffect Actually Works - And Why Developers Misuse It
useEffect is one of the most used hooks in React and also one of the most misunderstood. This guide covers the dependency array, cleanup functions, the double render, and common mistakes that cause bugs.
How React's useEffect Actually Works - And Why Developers Misuse It
If you've been writing React for a while, you've used useEffect. Probably a lot. But if someone asked you to explain exactly when it runs, why it sometimes runs twice, or when you should avoid it entirely - could you answer confidently?
Most developers can't. And that's not because they're bad developers. It's because useEffect is one of those things that's easy to use but genuinely tricky to understand. You can build a lot of stuff with it while still having a fuzzy mental model of what's actually happening.
This blog is about fixing that.
Start Here - What useEffect Is Actually For
React components have one job: take some data (props and state) and return UI. That's it. The function runs, returns JSX, done.
But real apps need to do things beyond just rendering UI. Fetching data from an API. Setting up an event listener. Starting a timer. Connecting to a WebSocket. These are called side effects - things that happen outside of the normal render flow and interact with the world beyond your component.
useEffect is React's way of saying: "run this code after the render, as a side effect."
import { useEffect } from "react";
function ProfilePage({ userId }) {
useEffect(() => {
console.log("Component rendered");
});
return <div>Profile Page</div>;
}
This effect runs after every render. But that's rarely what you want, which brings us to the dependency array.
The Dependency Array - The Part Everyone Gets Wrong
useEffect takes two arguments. The first is the function you want to run. The second is the dependency array - and this is where most of the confusion lives.
useEffect(() => {
// your effect code
}, [dependency1, dependency2]);
The dependency array tells React when to re-run the effect. There are three cases:
No dependency array - runs after every render:
useEffect(() => {
console.log("Runs after every single render");
});
Every time your component re-renders for any reason - state change, parent re-render, anything - this effect runs. This is almost never what you want.
Empty dependency array - runs only once after the first render:
useEffect(() => {
console.log("Runs once when component mounts");
}, []);
This runs exactly once - right after the component first appears on the screen. Common use case: fetching initial data.
With dependencies - runs when those specific values change:
useEffect(() => {
console.log("userId changed, fetching new profile");
fetchUserProfile(userId);
}, [userId]);
This runs after the first render and then again any time userId changes. If userId stays the same between renders, the effect is skipped.
The mental model worth keeping: React compares the current dependency values with the previous ones. If any of them changed, the effect runs again.
The Cleanup Function - Most Developers Skip This
Here's something a lot of developers don't know: useEffect can return a function. That returned function is the cleanup function and React calls it before running the effect again and when the component unmounts.
useEffect(() => {
const intervalId = setInterval(() => {
console.log("tick");
}, 1000);
// This is the cleanup function
return () => {
clearInterval(intervalId);
};
}, []);
Without the cleanup here, every time this component mounts a new interval starts - and when the component unmounts, the interval keeps running in the background. Memory leak.
Another classic example with event listeners:
useEffect(() => {
function handleResize() {
setWindowWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
If you add an event listener inside useEffect and don't remove it in the cleanup, you end up with multiple listeners stacking up every time the component mounts. The cleanup function is not optional when you're working with timers, listeners, or subscriptions.
Why Does useEffect Run Twice in Development?
This catches almost every React developer off guard the first time they see it.
You write a useEffect with an empty dependency array. You expect it to run once. But in development, you check the console and it runs twice. What's going on?
This is intentional behavior introduced in React 18. In development mode, React's Strict Mode deliberately mounts your component, unmounts it, and mounts it again. The purpose is to help you find bugs - specifically, effects that don't clean up properly.
React is essentially doing this on purpose: "Let's simulate what happens if this component mounts, unmounts, and mounts again. Does the app still work correctly?"
If your effect breaks when run twice, that's a signal that you're missing a cleanup function.
// This will cause issues if run twice
useEffect(() => {
fetchUserData(); // called twice in dev - not ideal but usually fine
}, []);
// This will definitely cause issues if run twice
useEffect(() => {
const socket = connectToWebSocket();
// No cleanup - two connections open in dev
}, []);
// This is correct
useEffect(() => {
const socket = connectToWebSocket();
return () => socket.disconnect(); // cleaned up properly
}, []);
This double-invocation only happens in development. In production, effects run exactly once when the dependency array is empty. So if your app behaves differently in prod vs dev, check your cleanup functions.
The Mistakes Developers Actually Make
1. Fetching Data Without Handling Cleanup
This is probably the most common one:
// Problematic version
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data)); // can cause "state update on unmounted component" warning
}, [userId]);
If the user navigates away before the fetch completes, the component unmounts. But the fetch is still running and when it finishes, it tries to call setUser on a component that no longer exists.
The fix is to use an abort controller:
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setUser(data))
.catch(err => {
if (err.name === "AbortError") return; // ignore aborted requests
console.error(err);
});
return () => controller.abort();
}, [userId]);
Now when the component unmounts, the fetch is cancelled cleanly.
2. Missing Dependencies
React has an ESLint rule called exhaustive-deps that warns you when you're using a value inside useEffect but not including it in the dependency array. A lot of developers ignore or suppress this warning - which often causes subtle bugs.
// Bug waiting to happen
useEffect(() => {
document.title = `Hello, ${userName}`; // uses userName
}, []); // but userName is not in the dependency array
// If userName changes, the title never updates
// Correct
useEffect(() => {
document.title = `Hello, ${userName}`;
}, [userName]); // now it updates whenever userName changes
Don't ignore the exhaustive-deps warning. It exists for a reason.
3. Putting Everything in useEffect
This one is more of a design problem. A lot of developers reach for useEffect as the default place to "do stuff" - but a lot of things that end up in useEffect don't need to be there at all.
Transforming data for display? Do it during render, not in an effect.
// Unnecessary effect
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// Just do this instead
const fullName = `${firstName} ${lastName}`;
Calculating derived state from existing state is not a side effect. Effects are for interacting with things outside React - APIs, the DOM, browser APIs, subscriptions.
4. Infinite Loops
This one causes the most panic:
// Infinite loop
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // triggers re-render, effect runs again, triggers re-render...
}, [count]);
Every time count changes the effect runs, which changes count, which runs the effect again. If you ever see your app freezing or a "too many re-renders" error, check if you're updating state inside an effect that depends on that same state.
When You Should Not Use useEffect
The React team themselves have said this clearly in their documentation: a lot of useEffect usage is unnecessary and makes code harder to understand.
Here are cases where you should skip useEffect:
Transforming data for rendering - Just compute it directly in the component body.
Handling user events - Use event handlers, not effects. If you're writing useEffect that reacts to a button click, something is off.
Initializing app-level state - If you need something to run once when the app loads, put it outside the component entirely, not inside an effect.
The honest rule is: if you find yourself writing useEffect and thinking "this feels complicated", stop and ask whether it actually needs to be an effect at all.
A Clean useEffect Looks Like This
When you do need useEffect, here's what a clean, correct implementation looks like:
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
})
.catch(err => {
if (err.name === "AbortError") return;
setLoading(false);
});
return () => controller.abort();
}, [userId]);
if (loading) return <p>Loading...</p>;
if (!user) return <p>User not found</p>;
return <h1>{user.name}</h1>;
}
Clear dependency array. Cleanup function. Error handling. Aborts stale requests. This is what using useEffect correctly actually looks like.
The Mental Model Worth Keeping
After all of this, here's the simplest way to think about useEffect:
It runs after the render, not during
The dependency array controls when it runs again
Always ask: does this need to be cleaned up?
If you don't need to interact with something outside React, you probably don't need
useEffectat all
Once these four things are second nature, you'll write fewer bugs, your components will be easier to reason about, and you'll stop reaching for useEffect as the solution to every problem.
Related Articles
More insights you might enjoy

SSR, CSR, SSG, and ISR in Next.js - What They Are and When to Use Each One
Next.js gives you four ways to render pages and each solves a different problem. This guide explains all four with real code examples and a practical decision guide.

React vs Next.js: What's Actually Different and When to Use Which
React and Next.js are often confused - one is a UI library, the other is a full framework built on top of it. This post breaks down the real differences: rendering strategies, routing, API routes, and when to pick one over the other.

HTTP Status Codes Explained - What They Actually Mean and Why Developers Should Know Them
Most developers know 200 and 404. But understanding the full range - and when to use them in your own APIs - makes you a noticeably better backend developer.

Comments
Sign in to join the conversation