Skip to main content

πŸ”„ Component Lifecycle and Effects

Components are born, they update, and eventually they disappear. Anything that reaches outside React during that life β€” fetching data, starting a timer, subscribing to an event β€” is a side effect, and the useEffect hook is how you run those effects at the right moment and clean them up when they're no longer needed.

🎯 Learning Objectives

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

  • Describe the three phases of a component's lifecycle: mount, update, unmount
  • Identify side effects and explain why they belong in useEffect
  • Control effect timing with the dependency array (none, [], and [deps])
  • Write cleanup functions to remove listeners, clear timers, and cancel work
  • Avoid the classic traps: infinite loops, missing dependencies, stale closures, and race conditions

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

Hands-on: Build a start/pause interval timer with correct effect cleanup.

In This Lesson

The Component Lifecycle

Every component passes through three phases. Understanding them tells you when your code runs relative to the screen updating.

flowchart TD A[Mount] --> A1[Component first added to the DOM] A1 --> B[Update] B --> B1[Props or state change β†’ re-render] B1 --> B B --> C[Unmount] C --> C1[Component removed from the DOM]
  • Mount β€” the component is created and inserted into the page for the first time.
  • Update β€” its props or state change, so it re-renders (this can happen many times).
  • Unmount β€” it is removed from the page, and any resources it set up must be released.

Class components used to expose named methods for these moments β€” componentDidMount, componentDidUpdate, componentWillUnmount. Function components unify all three into one hook: useEffect. Rather than thinking "run this on mount," the modern mindset is "keep this effect synchronized with these values."

πŸ“– Key Term β€” Synchronization

The modern way to reason about effects: an effect keeps something outside React (a subscription, the document title, a network request) in sync with your component's current props and state. React runs and re-runs it as needed to maintain that sync.

What Is a Side Effect?

Rendering should be pure: given the same props and state, a component should return the same JSX and touch nothing else. A side effect is any operation that reaches outside that pure calculation. Common examples:

  • Fetching data from an API
  • Setting up subscriptions or event listeners
  • Starting timers (setTimeout, setInterval)
  • Reading or writing browser APIs (localStorage, geolocation, the document title)
  • Manually interacting with a non-React widget
πŸ’‘ The restaurant analogy: Your visit's main purpose is to eat (render the UI). But to make that happen you place an order (a side effect) β€” an action directed outside your table. And when you leave, you settle the bill (cleanup). Effects are the orders your component places with the outside world.

Side effects don't belong in the render body because renders can happen often and at unpredictable times. useEffect gives them a controlled home that runs after the screen updates.

The useEffect Hook

useEffect takes two arguments: a function containing your effect, and an optional dependency array that controls when it runs. The effect can optionally return a cleanup function.

import { useEffect } from 'react';

function MyComponent() {
  useEffect(() => {
    // Effect: runs after the render is painted to the screen
    console.log('synced');

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

  return <div>Hello</div>;
}

Crucially, effects run after React has committed the render and the browser has painted. That ordering keeps the UI responsive β€” your effect never blocks the screen from updating.

sequenceDiagram participant C as Component participant R as React participant D as DOM participant E as Effect C->>R: Render (returns JSX) R->>D: Commit changes to DOM Note over D: Browser paints the screen R->>E: Run effect (after paint) Note over E: May call setState β†’ new render

The Dependency Array

The second argument is the control dial for how often your effect runs. There are exactly three cases:

Dependency arrayWhen the effect runsClass-era equivalent
OmittedAfter every rendercomponentDidMount + componentDidUpdate
[] (empty)Once, after the first rendercomponentDidMount
[a, b]After the first render, then whenever a or b changescomponentDidMount + conditional componentDidUpdate

React compares each dependency to its previous value with Object.is and re-runs the effect only if one changed. The golden rule: every value from component scope that the effect uses must appear in the array. Leaving one out causes the effect to read a stale value.

function ProductList({ category, sortBy, page }) {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    // Re-runs whenever any of the three inputs change
    fetchProducts(category, sortBy, page).then(setProducts);
  }, [category, sortBy, page]);

  return products.map(p => <ProductCard key={p.id} product={p} />);
}

πŸ’‘ Let the linter help

The eslint-plugin-react-hooks rule exhaustive-deps flags missing dependencies for you. Trust it β€” most "why is my effect stale?" bugs are a dependency you forgot to list.

Cleanup Functions

If your effect sets up something ongoing β€” an event listener, a timer, a subscription β€” you must tear it down. Return a cleanup function from the effect. React runs it before the effect runs again, and once more when the component unmounts.

function WindowSize() {
  const [size, setSize] = useState({ w: window.innerWidth, h: window.innerHeight });

  useEffect(() => {
    const handleResize = () =>
      setSize({ w: window.innerWidth, h: window.innerHeight });

    window.addEventListener('resize', handleResize);

    // Cleanup: remove the listener so it doesn't pile up or fire after unmount
    return () => window.removeEventListener('resize', handleResize);
  }, []); // set up once, tear down on unmount

  return <p>{size.w} Γ— {size.h}</p>;
}
πŸ’‘ The subscription analogy: Subscribing to a newspaper when you move into an apartment means cancelling it when you move out. Cleanup is telling the delivery to stop β€” without it, papers keep arriving at an address no one lives at, wasting resources and causing errors.

Common things to clean up: event listeners (removeEventListener), timers (clearTimeout/clearInterval), WebSocket or observable subscriptions, and in-flight requests. The stale closure trap makes cleanup especially important with timers:

function Ticker() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // Functional update avoids capturing a stale `count`
      setCount(prev => prev + 1);
    }, 1000);

    return () => clearInterval(id); // stop the interval on unmount
  }, []); // empty deps is fine because we used the functional update

  return <p>{count}</p>;
}

Data Fetching & Race Conditions

Fetching in an effect is a classic use case β€” but it hides a subtle bug. If a dependency changes quickly (say the user switches profiles), an earlier request can resolve after a later one, overwriting fresh data with stale data. This is a race condition. The fix is a cleanup flag (or an AbortController) that ignores results from a superseded effect.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false; // becomes true when this effect is superseded
    const controller = new AbortController();

    async function load() {
      try {
        setLoading(true);
        const res = await fetch(`/api/users/${userId}`, { signal: controller.signal });
        if (!res.ok) throw new Error('Failed to load user');
        const data = await res.json();
        if (!ignore) setUser(data);          // only the latest effect wins
      } catch (err) {
        if (!ignore && err.name !== 'AbortError') setError(err.message);
      } finally {
        if (!ignore) setLoading(false);
      }
    }

    load();

    return () => {
      ignore = true;        // ignore this request's result
      controller.abort();   // and cancel it if still in flight
    };
  }, [userId]); // re-fetch whenever userId changes

  if (loading) return <p>Loading…</p>;
  if (error) return <p>Error: {error}</p>;
  return <h2>{user.name}</h2>;
}

⚠️ In real apps, prefer a data library

Hand-written fetch effects work, but managing loading, errors, caching, retries, and races by hand gets old fast. For production, reach for TanStack Query or SWR β€” they handle all of this and dedupe requests. Use raw useEffect fetching to learn the mechanics; use a library to ship.

Hands-on Exercise

πŸ‹οΈ Build a Start/Pause Timer

Objective: Practice effect setup and cleanup with a controllable interval.

Requirements:

  1. Display a number of seconds, starting at 0.
  2. A Start/Pause button toggles a running boolean.
  3. While running, increment the count every second; while paused, stop.
  4. A Reset button sets the count back to 0.
  5. The interval must be cleaned up whenever it pauses or the component unmounts β€” no leaks.
πŸ’‘ Hint

Put running in the dependency array. When it's true, start an interval in the effect and return a cleanup that clears it. When running flips to false, React runs the cleanup automatically. Use the functional update form inside setInterval so you don't need count as a dependency.

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

function Timer() {
  const [seconds, setSeconds] = useState(0);
  const [running, setRunning] = useState(false);

  useEffect(() => {
    if (!running) return; // paused: no interval, nothing to clean up

    const id = setInterval(() => {
      setSeconds(prev => prev + 1); // functional update β€” no stale count
    }, 1000);

    return () => clearInterval(id); // runs on pause and on unmount
  }, [running]);

  return (
    <div>
      <p>{seconds}s</p>
      <button onClick={() => setRunning(r => !r)}>
        {running ? 'Pause' : 'Start'}
      </button>
      <button onClick={() => { setRunning(false); setSeconds(0); }}>
        Reset
      </button>
    </div>
  );
}

The key insight: making the effect depend on running lets React start and stop the interval for you through the normal setup/cleanup cycle.

🎯 Quick Quiz

Question 1: An effect with an empty dependency array [] runs…

Question 2: This effect causes an infinite loop. Why? useEffect(() => { setCount(count + 1); }, [count]);

Question 3: Why return a cleanup function from an effect that adds a resize listener?

Common Mistakes

⚠️ The usual suspects

  • Missing dependencies. Omitting a value the effect reads makes it use a stale version. List every one; let exhaustive-deps catch you.
  • Infinite loops. An effect that sets a state value listed in its own dependency array re-triggers itself forever.
  • Stale closures in timers. Using count directly inside setInterval captures the mount-time value; use the functional updater setCount(prev => ...).
  • Forgotten cleanup. Listeners, intervals, and sockets that are never torn down cause leaks and duplicate handlers.
  • Race conditions. Overlapping fetches can land out of order; guard with an ignore flag or AbortController.

βœ… You might not need an effect

Effects are for synchronizing with external systems. If you're only transforming data for rendering or responding to a user event, you often don't need an effect at all β€” compute the value during render, or do the work in the event handler. Overusing effects is a common source of bugs.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Components live through mount β†’ update β†’ unmount; useEffect handles all three in function components.
  • A side effect reaches outside pure rendering β€” fetching, timers, listeners, browser APIs.
  • The dependency array controls timing: omitted (every render), [] (once), [deps] (when deps change).
  • Return a cleanup function to remove listeners, clear timers, and cancel work.
  • Guard async effects against race conditions, and reach for a data library to ship real fetching.

πŸ“š Further Reading

πŸš€ What's Next?

Your components can now remember data and synchronize with the outside world. Next we focus on how users drive them: React's Event Handling System β€” synthetic events, passing arguments to handlers, and the patterns that keep interactive UIs clean.

πŸŽ‰ In sync!

Effects connected, cleanup handled. On to handling user events.