Skip to content
elephantoo

useEffect & the component lifecycle

Lesson 10 of 30 18 min read

Mount, update and unmount, dependency arrays, cleanup, Strict Mode and when not to use effects.


Most of your component code is pure rendering: props and state in, JSX out. But apps also need to talk to the outside world — set the page title, start timers, subscribe to events or websockets, measure the DOM, sync with a non-React widget, or fetch data. useEffect is React's tool for synchronising a component with an external system. This lesson explains exactly when effects run, how dependencies and cleanup work, what Strict Mode is doing, and when you don't need an effect at all.

The basic shape#

JSX
import { useEffect, useState } from "react";

export default function PageTitle() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // runs after React has updated the DOM
    document.title = `You clicked ${count} times`;
  }, [count]); // dependencies

  return <button onClick={() => setCount(count + 1)}>Click ({count})</button>;
}

useEffect(setup, dependencies):

  1. React renders your component and updates the screen.
  2. Then it runs setup. Effects never block painting.
  3. On later renders, React compares each dependency with the previous render's value. If any changed, it runs the cleanup (if any) and then setup again.

The component lifecycle#

Every component goes through three phases:

  • Mount — it appears on screen for the first time.
  • Update — it re-renders because its props or state changed.
  • Unmount — it's removed from the screen.

Effects let you hook into these moments, but the better mental model is synchronisation: "keep this external thing in sync with these values". React starts the sync on mount, re-syncs when values change, and stops on unmount.

Dependency arrays#

DependenciesThe effect runs…
[a, b]after mount, and after any render where a or b changed
[]after mount only (and its cleanup on unmount)
(omitted)after every render — rarely what you want
JSX
import { useEffect, useState } from "react";

function Logger({ courseId }) {
  const [tab, setTab] = useState("overview");

  useEffect(() => {
    console.log("mounted");
    return () => console.log("unmounted");
  }, []);

  useEffect(() => {
    console.log(`viewing ${courseId}/${tab}`);
  }, [courseId, tab]);

  return (
    <nav>
      <button onClick={() => setTab("overview")}>Overview</button>
      <button onClick={() => setTab("lessons")}>Lessons</button>
    </nav>
  );
}

Dependencies aren't a choice

The rule: every reactive value used inside the effect (props, state, and variables or functions declared in the component body) must be in the dependency array. The react-hooks/exhaustive-deps lint rule checks this. If you leave one out, the effect keeps using a stale value from an old render:

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // ❌ always 0 + 1 – `count` is stale
    }, 1000);
    return () => clearInterval(id);
  }, []); // count is missing from dependencies

  return <p>{count}</p>; // stuck at 1
}

The fix here isn't to add count (that would restart the interval every second) but to remove the dependency with an updater function:

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

  useEffect(() => {
    const id = setInterval(() => setCount((c) => c + 1), 1000); // ✅ no need to read count
    return () => clearInterval(id);
  }, []);

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

If the linter wants a dependency you don't want, change the code so it doesn't need it — don't silence the linter.

Objects and functions as dependencies

Dependencies are compared with Object.is. An object or function created during render is a new value every render, so it re-runs the effect every time:

JSX
function ChatRoom({ roomId, serverUrl }) {
  // ❌ `options` is a new object every render → reconnects on every render
  // const options = { roomId, serverUrl };
  // useEffect(() => { const c = createConnection(options); c.connect(); return () => c.disconnect(); }, [options]);

  // ✅ create the object inside the effect and depend on the primitives
  useEffect(() => {
    const connection = createConnection({ roomId, serverUrl });
    connection.connect();
    return () => connection.disconnect();
  }, [roomId, serverUrl]);

  return <h2>Welcome to {roomId}</h2>;
}

Cleanup#

If your effect starts something, the cleanup stops it:

JSX
import { useEffect, useState } from "react";

export default function WindowSize() {
  const [width, setWidth] = useState(() => window.innerWidth);

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

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

React runs the cleanup:

  • before re-running the effect (with the old values), and
  • when the component unmounts.

Typical pairs: addEventListener/removeEventListener, setInterval/clearInterval, connect/disconnect, subscribe/unsubscribe, start a request/abort() it.

Strict Mode runs effects twice (in development)#

With <StrictMode> (on by default in Vite projects), React mounts every component, unmounts it, and mounts it again in development. You'll see effects run, clean up and run again:

Output
mounted
unmounted
mounted

This is deliberate: it simulates the user navigating away and back, and immediately exposes effects that don't clean up properly (duplicate subscriptions, two intervals, leaking listeners). If your effect breaks when run twice, it needs a cleanup. Production builds run effects once.

Don't "fix" it with a ref that prevents the second run — fix the cleanup.

Effects run after paint; useLayoutEffect runs before#

Occasionally you need to measure the DOM and adjust before the user sees anything (e.g. positioning a tooltip). useLayoutEffect has the same API but runs synchronously after DOM updates and before the browser paints. It blocks painting, so use it only for measure-then-adjust layout work; useEffect is right 99% of the time.

You might not need an effect#

Effects are an escape hatch from React. Many beginners overuse them; unnecessary effects make code slower, more complex and buggier. Common cases where you don't need one:

1. Deriving data from props or state

JSX
function FullName({ first, last }) {
  // ❌ extra state + effect: renders twice, briefly shows stale value
  // const [fullName, setFullName] = useState("");
  // useEffect(() => setFullName(`${first} ${last}`), [first, last]);

  // ✅ compute during render
  const fullName = `${first} ${last}`;
  return <h2>{fullName}</h2>;
}

If the calculation is expensive, wrap it in useMemo (see the memoisation lesson) — still no effect.

2. Responding to user events

JSX
function BuyButton({ product }) {
  // ❌ setting a flag, then reacting to it in an effect
  // const [bought, setBought] = useState(false);
  // useEffect(() => { if (bought) fetch("/api/buy", { method: "POST" }); }, [bought]);

  // ✅ do it in the handler – you know exactly what happened
  async function handleClick() {
    await fetch("/api/buy", { method: "POST", body: JSON.stringify({ id: product.id }) });
  }
  return <button onClick={handleClick}>Buy {product.name}</button>;
}

Code that runs because the user did something belongs in an event handler. Code that runs because the component is displayed belongs in an effect.

3. Resetting state when a prop changes

JSX
// ❌ useEffect(() => setComment(""), [userId]);

// ✅ give the component a key – React creates a fresh instance (with fresh state)
<ProfilePage userId={userId} key={userId} />;

4. Notifying a parent

Call the parent's callback in the same event handler that changes the state, rather than in an effect that watches it.

A good effect: syncing with a browser API#

JSX
import { useEffect, useState } from "react";

export default function OnlineStatus() {
  const [online, setOnline] = useState(() => navigator.onLine);

  useEffect(() => {
    const goOnline = () => setOnline(true);
    const goOffline = () => setOnline(false);
    window.addEventListener("online", goOnline);
    window.addEventListener("offline", goOffline);
    return () => {
      window.removeEventListener("online", goOnline);
      window.removeEventListener("offline", goOffline);
    };
  }, []);

  return <p>{online ? "✅ Online" : "❌ Offline — changes will sync later"}</p>;
}

(For subscribing to external stores like this, React also offers useSyncExternalStore, which handles some edge cases for you.)

Effects and data fetching#

Fetching data in an effect works and is covered in the next lesson, but it has pitfalls (race conditions, no caching, waterfalls). In real apps, prefer a library like TanStack Query or your framework's data loading (React Router loaders, Next.js Server Components).

Common mistakes#

  • Missing dependencies → stale values. Trust the linter.
  • Objects/functions created during render as dependencies → effect runs every render.
  • Setting state unconditionally in an effect with no dependency array → infinite loop.
  • Forgetting cleanup → duplicate listeners, timers and connections (Strict Mode reveals this).
  • Using effects to derive state or handle user events.
  • Making the effect callback itself async — it must return nothing or a cleanup function, not a promise. Define an async function inside and call it.

What's next#

Next, apply all of this to the most common effect of all: fetching data, done properly.

Check your understanding

Quick quiz

0/3 answered
  1. 1.When does an effect with dependencies [userId] run?

  2. 2.What is the cleanup function returned from an effect for?

  3. 3.You have firstName and lastName in state and want fullName. What should you do?

Finished reading?

Mark this lesson complete to track your progress.