Skip to main content

♻️ Effects and Component Lifecycle

Components render pure UI from props and state β€” but real apps also need to fetch data, subscribe to events, and set timers. Those are side effects, and useEffect is how you run them safely. This lesson demystifies the component lifecycle and the one hook that trips up almost every React beginner.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Describe the three lifecycle phases β€” mount, update, unmount β€” and where effects fit
  • Use useEffect with the right dependency array to control when it runs
  • Write cleanup functions that prevent memory leaks from listeners, timers, and requests
  • Fetch data safely with AbortController, handling loading, error, and cancellation
  • Recognize and fix the classic bugs: stale closures, infinite loops, and unstable object dependencies

Estimated Time: 40–50 minutes  β€’  Difficulty: Intermediate

Hands-on: Build a debounced live-search component that fetches results and cancels stale requests.

In This Lesson

The Component Lifecycle

Every component moves through three phases. It is mounted (added to the screen for the first time), then possibly updated many times as its props or state change, and finally unmounted (removed from the screen).

flowchart LR A([Mount]) --> B([Update]) B --> B B --> C([Unmount]) style A fill:#dcfce7,stroke:#22c55e style B fill:#eff6ff,stroke:#3b82f6 style C fill:#fef2f2,stroke:#ef4444

Rendering itself must be pure: given the same props and state, a component returns the same JSX and touches nothing outside itself. But apps need to reach outside β€” call an API, add a scroll listener, start a countdown. Those interactions with the outside world are side effects, and they must happen after render, not during it.

πŸ“– Key Term: Side Effect

Any work that reaches beyond returning JSX β€” fetching data, subscribing to events, setting timers, or manually touching the DOM or document.title. React gives you useEffect to run these safely at the right moment in the lifecycle.

If you've seen old React class components, effects replace the trio of lifecycle methods below. One hook now covers all three jobs:

Old class methodLifecycle momentuseEffect equivalent
componentDidMountAfter first renderuseEffect(fn, [])
componentDidUpdateAfter a dependency changesuseEffect(fn, [dep])
componentWillUnmountBefore removalthe return function inside the effect

Meet useEffect

useEffect takes two arguments: a setup function and an optional dependency array. React runs the setup function after the component renders and the DOM is painted.

import { useEffect } from 'react';

useEffect(() => {
  // setup: runs after render
  console.log('Component rendered');

  return () => {
    // cleanup: runs before the next effect and before unmount
    console.log('Cleaning up');
  };
}, [/* dependencies */]);

The best mental model is synchronization, not lifecycle events. An effect keeps something outside React (a subscription, the document title, a network request) in sync with your component's current props and state. When the relevant state changes, React re-synchronizes by cleaning up the old effect and running a fresh one.

πŸ’‘ A simple first effect. Updating the browser tab's title is a classic side effect β€” it touches document, which lives outside React:
function PageTitle({ unread }) {
  useEffect(() => {
    document.title = unread > 0 ? `(${unread}) Inbox` : 'Inbox';
  }, [unread]); // re-run only when the unread count changes

  return <h1>Inbox</h1>;
}

The Dependency Array

The second argument is the single most important part of useEffect. It tells React when to re-run the effect by listing the reactive values the effect depends on.

flowchart TD A[Component renders] --> B{Dependency array?} B -->|Omitted| C[Run effect every render] B -->|Empty array| D[Run once, after mount] B -->|"[a, b]"| E{Did a or b change?} E -->|Yes| F[Run effect] E -->|No| G[Skip effect] style D fill:#dcfce7,stroke:#22c55e style F fill:#eff6ff,stroke:#3b82f6 style G fill:#fef2f2,stroke:#ef4444
Dependency arrayWhen the effect runsTypical use
[] (empty)Once, right after mountOne-time setup: initial fetch, subscribe to an event
[userId]After mount, then whenever userId changesRefetch when the viewed user changes
omitted entirelyAfter every single renderRare β€” usually a bug; use with care

⚠️ The golden rule of dependencies

Every reactive value your effect reads β€” props, state, or functions defined in the component β€” must appear in the dependency array. Leaving one out doesn't "optimize" the effect; it hides a bug where the effect works with stale, outdated values. Install eslint-plugin-react-hooks and let it enforce this for you.

Cleanup Functions

If your effect sets something up that persists β€” an event listener, a timer, a WebSocket β€” you must tear it down. The function you return from the effect is the cleanup. React calls it before running the effect again and when the component unmounts.

πŸ“– The Campsite Rule

"Leave no trace." Whatever your effect brought into the world β€” a listener, a subscription, a running timer β€” the cleanup packs it back up before you move on. Skip cleanup and you leak memory and get ghost handlers firing on components that no longer exist.

Here's a component that tracks the window width. Without the cleanup, every time it remounts you'd stack another listener that never goes away:

import { useState, useEffect } from 'react';

function WindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);

    // Cleanup: remove the exact listener we added.
    return () => window.removeEventListener('resize', handleResize);
  }, []); // set up once on mount, tear down on unmount

  return <p>Window width: {width}px</p>;
}

πŸ’‘ What needs cleanup?

  • Event listeners β†’ removeEventListener
  • Timers β†’ clearTimeout / clearInterval
  • Subscriptions β†’ call the returned unsubscribe
  • WebSockets β†’ socket.close()
  • In-flight fetches β†’ controller.abort() (next section)

Data Fetching Done Right

Fetching in an effect is common, but naΓ―ve versions have a subtle bug: race conditions. If userId changes quickly, an older, slower request can resolve after a newer one and overwrite it with stale data. The fix is to cancel the previous request in the cleanup with an AbortController.

import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [status, setStatus] = useState('loading'); // 'loading' | 'error' | 'ready'

  useEffect(() => {
    const controller = new AbortController();
    setStatus('loading');

    async function load() {
      try {
        const res = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        setUser(data);
        setStatus('ready');
      } catch (err) {
        // Ignore the "we cancelled it" error; surface real ones.
        if (err.name !== 'AbortError') setStatus('error');
      }
    }

    load();

    // Cleanup cancels the request if userId changes or we unmount.
    return () => controller.abort();
  }, [userId]);

  if (status === 'loading') return <p>Loading…</p>;
  if (status === 'error') return <p>Could not load user.</p>;
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

βœ… Why this is robust

When userId changes, React runs the cleanup first (controller.abort()) so the stale request can never win the race. Only the newest request's result reaches state.

⚠️ In real apps, don't hand-roll fetching

This pattern is worth understanding, but production code usually delegates fetching to a library like TanStack Query or SWR, which handle caching, retries, deduplication, and background refresh for you. Use raw useEffect fetching for learning and simple one-offs.

Three Classic Mistakes

1. Missing dependency β†’ stale closure

Leaving a used value out of the array freezes it at the value from the render when the effect last ran.

// ❌ Wrong: reads userId but omits it β€” always uses the first userId.
useEffect(() => {
  fetchResults(query, userId).then(setResults);
}, [query]);

// βœ… Right: every value the effect reads is listed.
useEffect(() => {
  fetchResults(query, userId).then(setResults);
}, [query, userId]);

2. Updating a dependency inside the effect β†’ infinite loop

If the effect sets state that it also depends on, it re-runs forever.

// ❌ Infinite loop: count changes β†’ effect runs β†’ count changes β†’ …
useEffect(() => {
  setCount(count + 1);
}, [count]);

// βœ… If you truly need to react to something else, depend on THAT,
//    and use the updater form so you don't depend on count at all.
useEffect(() => {
  setCount((c) => c + 1);
}, [someTrigger]);

3. Object/array dependency β†’ runs every render

React compares dependencies by reference. An object or array literal is a brand-new reference every render, so the effect never gets to skip.

// ❌ `options` is a new object each render β†’ effect runs every time.
function Card({ user }) {
  const options = { showDetails: true };
  useEffect(() => {
    fetchDetails(user.id, options);
  }, [user.id, options]);
}

// βœ… Stabilize the reference with useMemo (or move it outside the component).
function Card({ user }) {
  const options = useMemo(() => ({ showDetails: true }), []);
  useEffect(() => {
    fetchDetails(user.id, options);
  }, [user.id, options]);
}
πŸ’‘ Ask before reaching for an effect. Many things beginners put in effects don't belong there. Transforming data for rendering? Do it during render. Responding to a user click? Put it in the event handler. Effects are for synchronizing with external systems β€” if there's no external system, you probably don't need one.

Extracting Custom Hooks

When effect logic repeats across components, lift it into a custom hook β€” a function whose name starts with use and that calls other hooks. This is React's superpower for reusing stateful logic. Here's a reusable data-fetching hook built from the pattern above:

// useFetch.js
import { useState, useEffect } from 'react';

export function useFetch(url) {
  const [data, setData] = useState(null);
  const [status, setStatus] = useState('loading');

  useEffect(() => {
    const controller = new AbortController();
    setStatus('loading');

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((json) => {
        setData(json);
        setStatus('ready');
      })
      .catch((err) => {
        if (err.name !== 'AbortError') setStatus('error');
      });

    return () => controller.abort();
  }, [url]);

  return { data, status };
}

Now any component fetches with a single readable line, and the tricky cleanup lives in exactly one place:

function Posts() {
  const { data: posts, status } = useFetch('/api/posts');

  if (status === 'loading') return <p>Loading…</p>;
  if (status === 'error') return <p>Failed to load.</p>;

  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

Hands-on Exercise

πŸ‹οΈ Build a Debounced Live Search

Objective: Create a search box that waits until the user stops typing, then fetches matching results β€” cancelling any stale request. This combines timers, cleanup, and dependency arrays.

Instructions:

  1. Hold the raw input in query state and a delayed copy in debouncedQuery state.
  2. In one effect, start a 400ms setTimeout that copies query into debouncedQuery; clear the timer in the cleanup so fast typing keeps resetting it.
  3. In a second effect keyed on debouncedQuery, fetch https://jsonplaceholder.typicode.com/posts?q=… using an AbortController.
  4. Render the results, plus "Type to search…" when the query is empty.
πŸ’‘ Hint

Two effects, each with its own dependency array, keep the concerns clean: one debounces (depends on query), the other fetches (depends on debouncedQuery). Don't try to do both in a single effect.

βœ… Solution
import { useState, useEffect } from 'react';

function LiveSearch() {
  const [query, setQuery] = useState('');
  const [debouncedQuery, setDebouncedQuery] = useState('');
  const [results, setResults] = useState([]);

  // Effect 1 β€” debounce the input.
  useEffect(() => {
    const timer = setTimeout(() => setDebouncedQuery(query), 400);
    return () => clearTimeout(timer);
  }, [query]);

  // Effect 2 β€” fetch when the debounced value settles.
  useEffect(() => {
    if (!debouncedQuery) {
      setResults([]);
      return;
    }
    const controller = new AbortController();

    fetch(
      `https://jsonplaceholder.typicode.com/posts?q=${debouncedQuery}`,
      { signal: controller.signal }
    )
      .then((res) => res.json())
      .then(setResults)
      .catch((err) => {
        if (err.name !== 'AbortError') console.error(err);
      });

    return () => controller.abort();
  }, [debouncedQuery]);

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search posts…"
      />
      {!query && <p>Type to search…</p>}
      <ul>
        {results.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

🎯 Quick Quiz

Question 1: What does an empty dependency array [] tell useEffect to do?

Question 2: Why does fetching in an effect use an AbortController in the cleanup?

Question 3: An effect reads query and userId but its array is [query]. What's the likely bug?

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Components live through mount β†’ update β†’ unmount; effects run side effects after render.
  • Think of useEffect as synchronizing with an external system, not as lifecycle callbacks.
  • The dependency array controls timing; list every reactive value the effect reads.
  • Always clean up listeners, timers, subscriptions, and requests to avoid leaks and races.
  • Beware the big three: stale closures, infinite loops, unstable object deps β€” and lift repeated effect logic into custom hooks.

πŸ“š Further Reading

πŸš€ What's Next?

You've now handled inputs and events in passing β€” next we go deep on Form Handling in React: controlled vs. uncontrolled components, validation strategies, and the modern libraries that make complex forms painless.

πŸŽ‰ Effect mastery unlocked!

You can now safely connect your components to APIs, events, and timers β€” and clean up after yourself.