React Interview Prep
Hooks

useEffect

Side Effects

LinkedIn Hook

You put a fetch call inside your component, and it fires 47 times.

Sound familiar?

useEffect is 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


useEffect thumbnail


What You'll Learn

  • What side effects are and why React needs a dedicated hook for them
  • How the useEffect dependency 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 useEffect correctly
  • 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:

  1. A function (the effect) — the code you want to run
  2. 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)

useEffect visual 1


The Cleanup Function

The function you return from useEffect is the cleanup function. React calls it:

  1. Before re-running the effect (when dependencies change)
  2. 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

useEffect visual 2


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. useEffect lets 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 (or ignore) boolean flag. Set it to true in the cleanup function. Before calling setState with the fetched data, check if cancelled is still false. This ensures that if the component re-renders with new props before the fetch completes, the stale response is discarded. Alternatively, use an AbortController to 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 calls setCount again — 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.

On this page