useEffect
Side Effects
LinkedIn Hook
You put a
fetchcall inside your component, and it fires 47 times.Sound familiar?
useEffectis the most misused hook in React. Most developers can write one, but few can explain why it runs when it does, what the dependency array actually controls, or why skipping cleanup causes memory leaks in production.Interviewers love this hook because it separates people who copy patterns from people who understand the render cycle.
In this lesson, I break down side effects from scratch — what they are, how the dependency array works (
[],[dep], no array), why the cleanup function exists, and the infinite loop traps that catch even experienced developers.If you've ever stared at your console watching the same API call fire on repeat — this one's for you.
Read the full lesson -> [link]
#React #JavaScript #InterviewPrep #Frontend #CodingInterview #ReactHooks #useEffect #100DaysOfCode
What You'll Learn
- What side effects are and why React needs a dedicated hook for them
- How the
useEffectdependency array controls when your effect runs ([],[dep], no array) - The cleanup function — what it does, why it matters, and when it runs
- How to fetch data with
useEffectcorrectly - How to set up and tear down event listeners and timers safely
- The most common infinite loop mistakes and how to avoid them
The Concept — What Are Side Effects?
Analogy: The Restaurant Kitchen
Think of a React component as a chef preparing a plate of food. The chef's main job is to assemble the dish and send it out — that's rendering (returning JSX).
But sometimes the chef also needs to do things that have nothing to do with the plate itself: call a supplier to order ingredients, set a kitchen timer, or turn on the exhaust fan. These tasks happen alongside the main job but are separate from it. They interact with the outside world.
These are side effects. They are anything that reaches outside the component's rendering process:
- Fetching data from an API (calling the supplier)
- Setting up a timer or interval (kitchen timer)
- Adding an event listener to the window (turning on the exhaust fan)
- Manually changing the document title
- Writing to localStorage
React's rendering should be pure — the same props and state should always produce the same JSX. Side effects break that purity, so React gives you a designated place to put them: useEffect.
How useEffect Works
useEffect takes two arguments:
- A function (the effect) — the code you want to run
- A dependency array (optional) — controls when the effect re-runs
The Three Dependency Patterns
useEffect(() => { ... }); // No array — runs after EVERY render
useEffect(() => { ... }, []); // Empty array — runs ONCE after first render
useEffect(() => { ... }, [dep]); // With deps — runs when `dep` changes
This is the single most important thing to memorize about useEffect. Interviewers will test all three.
Code Example 1: The Three Patterns Side by Side
import { useState, useEffect } from "react";
function DemoComponent() {
const [count, setCount] = useState(0);
const [name, setName] = useState("Alice");
// Pattern 1: No dependency array — runs after every render
useEffect(() => {
console.log("Effect 1: I run after EVERY render");
});
// Pattern 2: Empty dependency array — runs once after mount
useEffect(() => {
console.log("Effect 2: I run ONCE when the component mounts");
}, []);
// Pattern 3: Specific dependencies — runs when `count` changes
useEffect(() => {
console.log("Effect 3: count changed to", count);
}, [count]);
return (
<div>
<p>{name}: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setName("Bob")}>Change Name</button>
</div>
);
}
// Initial render:
// "Effect 1: I run after EVERY render"
// "Effect 2: I run ONCE when the component mounts"
// "Effect 3: count changed to 0"
//
// Click "Increment" (count: 0 -> 1):
// "Effect 1: I run after EVERY render"
// "Effect 3: count changed to 1"
// (Effect 2 does NOT run — empty array means mount only)
//
// Click "Change Name" (name: "Alice" -> "Bob"):
// "Effect 1: I run after EVERY render"
// (Effect 3 does NOT run — count didn't change)
The Cleanup Function
The function you return from useEffect is the cleanup function. React calls it:
- Before re-running the effect (when dependencies change)
- When the component unmounts (is removed from the DOM)
This is how you prevent memory leaks. If you set something up (listener, timer, subscription), you must tear it down.
Code Example 2: Event Listener with Cleanup
import { useState, useEffect } from "react";
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
// Setup: add event listener when component mounts
function handleResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", handleResize);
// Cleanup: remove event listener when component unmounts
return () => {
window.removeEventListener("resize", handleResize);
};
}, []); // Empty array — set up once, clean up on unmount
return <p>Window width: {width}px</p>;
}
// Mount: adds resize listener
// User resizes browser: width updates in real time
// Unmount (navigate away): listener is removed — no memory leak
//
// Without cleanup: every time this component mounts, a NEW listener
// is added. After 10 mounts, 10 listeners are firing. Memory leak.
Code Example 3: Timer with Cleanup
import { useState, useEffect } from "react";
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const [isRunning, setIsRunning] = useState(false);
useEffect(() => {
// Only start the interval if isRunning is true
if (!isRunning) return;
// Setup: start a 1-second interval
const intervalId = setInterval(() => {
setSeconds(prev => prev + 1); // Functional update — avoids stale closure
}, 1000);
// Cleanup: clear the interval when isRunning changes or component unmounts
return () => {
clearInterval(intervalId);
};
}, [isRunning]); // Re-run when isRunning changes
return (
<div>
<p>Time: {seconds}s</p>
<button onClick={() => setIsRunning(!isRunning)}>
{isRunning ? "Stop" : "Start"}
</button>
</div>
);
}
// Click "Start" (isRunning: false -> true):
// Effect runs -> interval starts -> seconds tick up
// Click "Stop" (isRunning: true -> false):
// Cleanup runs -> interval cleared -> seconds freeze
// Effect runs again -> isRunning is false -> early return, no new interval
Fetching Data with useEffect
This is one of the most common interview questions. Here is the standard pattern:
Code Example 4: Data Fetching
import { useState, useEffect } from "react";
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Reset state when userId changes
setLoading(true);
setError(null);
// Track whether this effect is still "current"
let cancelled = false;
async function fetchUser() {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error("Failed to fetch");
const data = await response.json();
// Only update state if this effect hasn't been cleaned up
if (!cancelled) {
setUser(data);
setLoading(false);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
setLoading(false);
}
}
}
fetchUser();
// Cleanup: if userId changes before fetch completes, ignore the old result
return () => {
cancelled = true;
};
}, [userId]); // Re-fetch when userId changes
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <p>Hello, {user.name}!</p>;
}
// userId = 1: fetches user 1, displays name
// userId changes to 2 (before user 1 loads):
// Cleanup sets cancelled = true for old fetch
// New effect fetches user 2
// Old fetch completes but is ignored (cancelled === true)
// No race condition — user 2 data is displayed correctly
Why the cancelled flag? Without it, if the user switches from userId 1 to userId 2 quickly, the response for userId 1 might arrive after userId 2's response, overwriting the correct data with stale data. This is called a race condition.
Note: For production apps, consider libraries like React Query (TanStack Query) or SWR that handle caching, deduplication, and race conditions automatically. But interviewers expect you to know the raw useEffect pattern.
Common Mistakes
Mistake 1: The Infinite Loop — Setting State Without Dependencies
// INFINITE LOOP
function BadComponent() {
const [data, setData] = useState([]);
useEffect(() => {
fetch("/api/data")
.then(res => res.json())
.then(json => setData(json)); // setData triggers re-render
}); // No dependency array — runs after EVERY render
// Render -> effect -> setState -> re-render -> effect -> setState -> ...
return <p>{data.length} items</p>;
}
// FIX: Add an empty dependency array
useEffect(() => {
fetch("/api/data")
.then(res => res.json())
.then(json => setData(json));
}, []); // Runs once — no infinite loop
Mistake 2: Object/Array Dependencies Causing Unnecessary Re-runs
function SearchResults({ filters }) {
const [results, setResults] = useState([]);
// PROBLEM: If the parent re-renders, `filters` is a new object reference
// even if the values inside haven't changed — effect re-runs every time
useEffect(() => {
fetch(`/api/search?q=${filters.query}`)
.then(res => res.json())
.then(data => setResults(data));
}, [filters]); // New reference every render = runs every render
return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}
// FIX: Depend on primitive values, not object references
useEffect(() => {
fetch(`/api/search?q=${filters.query}`)
.then(res => res.json())
.then(data => setResults(data));
}, [filters.query]); // Primitive string — stable comparison
Mistake 3: Forgetting Cleanup on Timers and Listeners
// MEMORY LEAK — no cleanup
useEffect(() => {
const id = setInterval(() => {
console.log("tick");
}, 1000);
// Missing: return () => clearInterval(id);
}, []);
// Every time this component mounts/unmounts (e.g., page navigation),
// a new interval starts but the old one is never cleared.
// After 10 navigations: 10 intervals running simultaneously.
Always return a cleanup function when you set up subscriptions, listeners, or timers.
Interview Questions
Q: What are side effects in React, and why do we need useEffect?
Side effects are operations that interact with the outside world — data fetching, DOM manipulation, timers, subscriptions. React rendering should be pure (same input = same output), so side effects need a separate place.
useEffectlets you run side effects after React has committed the render to the DOM, keeping rendering predictable.
Q: What is the difference between useEffect(() => {}), useEffect(() => {}, []), and useEffect(() => {}, [dep])?
No array: runs after every render. Empty array: runs once after the initial render (mount). With dependencies: runs after the initial render and again whenever any dependency value changes. React compares dependencies using
Object.is.
Q: What is the cleanup function in useEffect, and when does it run?
The cleanup function is the function you return from the effect callback. React calls it in two situations: (1) before re-running the effect when dependencies change, and (2) when the component unmounts. It prevents memory leaks by letting you undo whatever the setup function did — removing listeners, clearing timers, cancelling requests.
Q: How do you prevent race conditions when fetching data in useEffect?
Use a
cancelled(orignore) boolean flag. Set it totruein the cleanup function. Before callingsetStatewith the fetched data, check ifcancelledis stillfalse. This ensures that if the component re-renders with new props before the fetch completes, the stale response is discarded. Alternatively, use anAbortControllerto cancel the fetch request entirely.
Q: Why does this code cause an infinite loop?
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
There is no dependency array, so the effect runs after every render. The effect calls
setCount, which triggers a re-render, which runs the effect again, which callssetCountagain — infinite loop. Fix: add appropriate dependencies or restructure the logic so the effect does not unconditionally update state that triggers itself.
Quick Reference — Cheat Sheet
+-----------------------------------+-------------------------------------------+
| Concept | Key Point |
+-----------------------------------+-------------------------------------------+
| useEffect(fn) | No array — runs after EVERY render. |
+-----------------------------------+-------------------------------------------+
| useEffect(fn, []) | Empty array — runs ONCE on mount. |
+-----------------------------------+-------------------------------------------+
| useEffect(fn, [a, b]) | Runs on mount + when a or b changes. |
+-----------------------------------+-------------------------------------------+
| Cleanup (return () => {}) | Runs before re-run and on unmount. |
| | Use for listeners, timers, subscriptions. |
+-----------------------------------+-------------------------------------------+
| Data fetching pattern | Use cancelled flag or AbortController |
| | to prevent race conditions. |
+-----------------------------------+-------------------------------------------+
| Dependency comparison | Uses Object.is — objects/arrays compared |
| | by reference, not by value. |
+-----------------------------------+-------------------------------------------+
| Infinite loop cause | Setting state inside effect without |
| | proper dependency array. |
+-----------------------------------+-------------------------------------------+
RULE: Always include a dependency array unless you truly need every-render behavior.
RULE: If you set it up, clean it up — return a cleanup function.
RULE: Depend on primitives, not object/array references.
Previous: Lesson 3.1 — useState Deep Dive -> Next: Lesson 3.3 — useRef — Escape Hatch ->
This is Lesson 3.2 of the React Interview Prep Course — 10 chapters, 42 lessons.