useEffect Done Right — The Hook That Causes More Bugs Than Any Other
Day 4 of 40 — React System Design Series

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.
Hey everyone, Richa here! 👋

Day 4. And today we're talking about the hook I have a love-hate relationship with.
useEffect.
I've seen it cause infinite loops in production. I've seen it fetch the same data four times. I've seen it fire on every single render because someone forgot the dependency array. I've written all of those bugs myself at some point.
And the frustrating thing? useEffect is not complicated once you understand what it actually is. But if you learn it by copying patterns without understanding the mental model — you will keep writing those bugs.
So today we go deep. Let's fix this properly.
First — what is useEffect actually for?

Here's the mental model shift that changes everything:
useEffectis not a lifecycle hook. It is a synchronisation tool.
Most people learn it as "componentDidMount + componentDidUpdate + componentWillUnmount" — the old class component lifecycle. That's how it was taught for years. It's also why people misuse it constantly.
The right way to think about it:
"After every render where these specific values changed — run this code to sync something outside React with the current state."
"Outside React" is the key phrase. useEffect is for things that are outside React's control:
- Fetching data from an API
- Setting up a WebSocket connection
- Adding a DOM event listener
- Syncing with
localStorage - Starting a timer or interval
- Calling a third-party library
If it doesn't involve something outside React, you probably don't need useEffect.
The three forms of useEffect
Form 1 — No dependency array (runs after every render)
useEffect(() => {
console.log('This runs after EVERY render');
});
Almost never what you want. Every state change, every prop update, every re-render — it fires. Leave this off by accident and you have an infinite loop waiting to happen.
Form 2 — Empty dependency array (runs once, on mount)
useEffect(() => {
console.log('This runs once, when the component first mounts');
}, []);
This is the closest thing to componentDidMount. Use it for one-time setup: fetching initial data, setting up subscriptions, connecting to a WebSocket.
Form 3 — With dependencies (runs when specific values change)
useEffect(() => {
console.log('This runs when userId changes');
}, [userId]);
This is the most common and most powerful form. React compares the dependency values after each render. If any of them changed — it runs the effect again.
The cleanup function — and why you almost always need one
Every useEffect can return a cleanup function. React runs it when:
- The component unmounts
- Before running the effect again (if the dependencies changed)
useEffect(() => {
// setup
const subscription = someAPI.subscribe(userId);
// cleanup
return () => {
subscription.unsubscribe();
};
}, [userId]);
Without cleanup — you have memory leaks, stale event listeners, and effects that keep running after the component is gone.
Let me show you a real example that bites people constantly:
// ❌ Bug — no cleanup, no abort controller
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => setUser(data)); // ← this can run after unmount!
}, [userId]);
return <div>{user?.name}</div>;
}
What happens here: user navigates to /profile/123, component mounts, fetch starts. Before the fetch finishes, user navigates away — component unmounts. The fetch still completes and calls setUser on an unmounted component. You get a React warning and potentially stale data showing up.
// ✅ Fixed — with AbortController
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
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; // expected, ignore it
console.error(err);
});
return () => controller.abort(); // cleanup cancels the fetch
}, [userId]);
return <div>{user?.name}</div>;
}
Now when userId changes or the component unmounts — the previous fetch is cancelled before the new one starts. Clean, safe, correct.
The dependency array — what actually goes in it
This is where most bugs come from. The rule is simple but people ignore it:
Every reactive value that the effect uses must be in the dependency array.
A reactive value is anything that can change between renders — props, state, variables derived from props or state.
// ❌ Missing dependency — stale closure bug
function SearchResults({ query }: { query: string }) {
const [results, setResults] = useState([]);
useEffect(() => {
fetchResults(query).then(setResults); // uses query
}, []); // ← but query is not in deps!
return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}
This fetches once on mount with the initial query value. If query changes later — the effect never re-runs. The results stay stale. This is a stale closure — the function "closed over" the old query value.
// ✅ Fixed
useEffect(() => {
fetchResults(query).then(setResults);
}, [query]); // ← query is included
Now every time query changes — the effect re-runs with the new value.
💡 Install the ESLint plugin:
eslint-plugin-react-hookswith theexhaustive-depsrule will catch missing dependencies automatically. This is not optional — it should be in every React project. It will save you hours of debugging.

The infinite loop — and how to avoid it
The most common useEffect disaster. Let's see exactly how it happens:
// ❌ Infinite loop
function BadComponent() {
const [data, setData] = useState([]);
useEffect(() => {
setData([1, 2, 3]); // updates state
}, [data]); // depends on data → triggers re-render → effect runs again → ...
}
Every time the effect runs, it updates data. Since data is in the dependency array, the effect runs again. Forever.
// ❌ Another infinite loop — objects and arrays
function AnotherBadComponent({ config }: { config: object }) {
const [result, setResult] = useState(null);
useEffect(() => {
processConfig(config).then(setResult);
}, [config]); // ← if config is a new object reference every render, this loops
}
This one is sneaky. Even if config contains the same values, if the parent creates a new object on every render (config={{ theme: 'dark' }}), React sees a new reference and re-runs the effect.
Solutions:
// Option 1 — useMemo to stabilise the reference
const stableConfig = useMemo(() => config, [config.theme, config.size]);
// Option 2 — depend on specific primitive values, not the object
useEffect(() => {
processConfig(config).then(setResult);
}, [config.theme, config.size]); // primitive values, not the object
A complete, real-world example — data fetching with all the pieces

Let me put it all together. Here's a proper data fetching hook with loading, error, and cleanup:
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
function useUserData(userId: string | null) {
const [state, setState] = useState<FetchState<User>>({ status: 'idle' });
useEffect(() => {
if (!userId) {
setState({ status: 'idle' });
return;
}
const controller = new AbortController();
setState({ status: 'loading' });
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
return res.json();
})
.then((data: User) => setState({ status: 'success', data }))
.catch(err => {
if (err.name === 'AbortError') return;
setState({ status: 'error', error: err.message });
});
return () => controller.abort();
}, [userId]);
return state;
}
And using it in a component:
function UserProfile({ userId }: { userId: string }) {
const userState = useUserData(userId);
if (userState.status === 'idle') return null;
if (userState.status === 'loading') return <Spinner />;
if (userState.status === 'error') return <ErrorMessage message={userState.error} />;
return (
<div>
<h1>{userState.data.name}</h1>
<p>{userState.data.email}</p>
</div>
);
}
Notice:
- The async state pattern from Day 3 (
idle | loading | success | error) — it pays off here AbortControllerfor cleanupuserIdin the dependency array — refetches when it changes- Guard for
nulluserId — no unnecessary fetch - The component itself is clean — just rendering, no effect logic
This is production-quality data fetching with useEffect. You could replace the internals with React Query later and the component wouldn't change at all.
When NOT to use useEffect
This is just as important. useEffect is overused. A lot of code I've seen in the wild uses it for things that don't need it at all.
Don't use useEffect to transform data for rendering:
// ❌ Unnecessary useEffect
function UserList({ users }: { users: User[] }) {
const [filtered, setFiltered] = useState(users);
useEffect(() => {
setFiltered(users.filter(u => u.active));
}, [users]);
return <ul>{filtered.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
// ✅ Just compute it during render
function UserList({ users }: { users: User[] }) {
const filtered = users.filter(u => u.active); // no effect needed
return <ul>{filtered.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
Don't use useEffect to reset state when props change:
// ❌ Unnecessary useEffect
function UserForm({ userId }: { userId: string }) {
const [name, setName] = useState('');
useEffect(() => {
setName(''); // reset form when userId changes
}, [userId]);
// ...
}
// ✅ Use the key prop instead — React will remount the component
<UserForm key={userId} userId={userId} />
Don't use useEffect to notify a parent of a state change:
// ❌ Unnecessary useEffect — causes an extra render
function Toggle({ onChange }: { onChange: (val: boolean) => void }) {
const [on, setOn] = useState(false);
useEffect(() => {
onChange(on); // notify parent after render
}, [on, onChange]);
// ...
}
// ✅ Call onChange directly in the event handler
function Toggle({ onChange }: { onChange: (val: boolean) => void }) {
const [on, setOn] = useState(false);
function handleClick() {
const newVal = !on;
setOn(newVal);
onChange(newVal); // one render, not two
}
// ...
}
Quick decision guide — do I need useEffect?
Ask yourself:
- Does this code touch something outside React? (API, DOM, timer, WebSocket, localStorage) →
useEffectis appropriate - Am I computing something from existing state/props? → Just compute it in the render function, no effect needed
- Am I syncing state between two state variables? → Usually a sign the state should be one thing, not two
- Am I responding to an event? → Put it in the event handler, not an effect
If you answer "no" to question 1 — you probably don't need useEffect.
Quick summary
| Pattern | Right? | Why |
|---|---|---|
useEffect with no dep array |
❌ Almost never | Runs after every render |
useEffect(fn, []) for one-time setup |
✅ Yes | Mount only, clean pattern |
useEffect(fn, [dep]) |
✅ Yes | Re-runs when dep changes |
| No cleanup for subscriptions/fetch | ❌ Memory leak | Always add cleanup |
| Missing dependency in array | ❌ Stale closure | Add it, use ESLint rule |
| Effect to compute derived data | ❌ Overkill | Compute in render |
| Effect to respond to events | ❌ Wrong tool | Use event handlers |
Today's takeaway

useEffect is not complicated — but it has a precise mental model.
It's a synchronisation tool for keeping things outside React in sync with your component's state. If you're not syncing something external — you probably don't need it.
The three rules to avoid every major bug:
- Always add cleanup when you set up anything that needs to be torn down
- Always include all reactive values in the dependency array (use the ESLint rule)
- Don't reach for useEffect when you can just compute during render or handle in an event handler
Once these click — the infinite loops and stale data bugs stop. Completely.

See you tomorrow for Day 5 — useMemo and useCallback: when memoisation actually helps (and when it's just noise).




