Topic 07 / 14

useEffect — Side Effects & Data Fetching

~12 min read  //  React Series  //  Coding India

What Effects Are For

Rendering must be pure: same props/state → same JSX, no side work. But real apps need to talk to the outside world — fetch data, set timers, subscribe to events, touch localStorage. useEffect runs that code after render:

import { useState, useEffect } from "react";

function Clock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const id = setInterval(() => setTime(new Date()), 1000);
    return () => clearInterval(id);     // cleanup — runs on unmount
  }, []);                                // [] = run once after first render

  return <p>{time.toLocaleTimeString()}</p>;
}

The Dependency Array — Three Modes

useEffect(() => { ... });            // no array: after EVERY render (rarely right)
useEffect(() => { ... }, []);        // empty: once, after the first render
useEffect(() => { ... }, [query]);   // after renders where query CHANGED

Rule: every reactive value used inside the effect belongs in the array. The ESLint plugin (react-hooks/exhaustive-deps) enforces this — trust it.

Cleanup

Return a function and React runs it before the next effect run and on unmount. Anything you start, you stop:

useEffect(() => {
  function onResize() { setWidth(window.innerWidth); }
  window.addEventListener("resize", onResize);
  return () => window.removeEventListener("resize", onResize);
}, []);

Data Fetching — the Full Pattern

function CourseList({ track }) {
  const [courses, setCourses] = useState([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    async function load() {
      setIsLoading(true);
      setError(null);
      try {
        const res = await fetch(`/api/courses?track=${track}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        setCourses(await res.json());
      } catch (err) {
        if (err.name !== "AbortError") setError(err.message);
      } finally {
        setIsLoading(false);
      }
    }
    load();

    return () => controller.abort();   // cancel if track changes mid-flight
  }, [track]);                          // refetch when track changes

  if (isLoading) return <Spinner />;
  if (error) return <p>Error: {error}</p>;
  return <ul>{courses.map(c => <li key={c.id}>{c.title}</li>)}</ul>;
}

The abort cleanup prevents the classic race: user switches track quickly, the slow old response lands last and overwrites the new data.

When NOT to Use an Effect

// WRONG — derived state doesn't need an effect
useEffect(() => {
  setFullName(first + " " + last);
}, [first, last]);

// RIGHT — just compute it during render
const fullName = first + " " + last;

Also wrong: effects that only respond to a button click (put the code in the event handler), and chains of effects setting state that triggers other effects. Effects are for synchronising with external systems — if no external system is involved, you probably don’t need one.

(Production apps usually move fetching into a library — TanStack Query — or a framework loader. But they all build on exactly this pattern, and you’ll write plenty of raw effects regardless.)