Skip to content
Laviyx Learn

Try

React course menu

React useEffect Hook Explained: Dependencies and Cleanup

Learn useEffect: synchronising components with things outside React, the dependency array, cleanup functions for timers and subscriptions, why effects run twice in Strict Mode, common mistakes like infinite loops, and when you do not need an effect.

Intermediate10 min readUpdated
On this page

Rendering must be pure: it calculates JSX and nothing else. But apps also need side effects: setting a timer, subscribing to window events, connecting to a chat server, updating the page title. useEffect runs code after React has updated the screen, to keep your component in sync with something outside React.

The basic shape#

JSX
useEffect(() => {
  // runs after rendering
  return () => {
    // optional cleanup: runs before the effect runs again, and when the component is removed
  };
}, [dependencies]);

Dependencies decide when it runs#

Dependency arrayEffect runs
none: useEffect(fn)after every render
empty: useEffect(fn, [])once, after the first render (on mount)
values: useEffect(fn, [a, b])after the first render and whenever a or b changed
JSX
import { useEffect, useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("");
  const [log, setLog] = useState([]);

  useEffect(() => {
    setLog((l) => [...l, `count effect ran (count = ${count})`]);
  }, [count]);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>count: {count}</button>{" "}
      <input value={name} onChange={(e) => setName(e.target.value)} placeholder="type here" />
      <ol>{log.map((line, i) => <li key={i}>{line}</li>)}</ol>
    </div>
  );
}
Result

The effect runs once at the start, then again only when count changes. Typing in the box re-renders the component but does not run the effect, because name is not a dependency.

The rule: list every value from the component (props, state, and things calculated from them) that the effect uses. The React linter (eslint-plugin-react-hooks) checks this for you.

Cleanup: timers#

A stopwatch needs a timer that starts and stops with the component. The cleanup function clears the timer:

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

function Stopwatch() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    const id = setInterval(() => setSeconds((s) => s + 1), 1000);
    return () => clearInterval(id);          // cleanup when the Stopwatch is removed
  }, []);

  return <p>⏱ {seconds} s</p>;
}

export default function App() {
  const [show, setShow] = useState(true);
  return (
    <div>
      <button onClick={() => setShow(!show)}>{show ? "Remove" : "Add"} stopwatch</button>
      {show && <Stopwatch />}
    </div>
  );
}
Result

Removing the stopwatch runs the cleanup and stops the timer; adding it again starts a fresh one at 0. Without the cleanup, every removed stopwatch would leave a timer running in the background, updating a component that no longer exists.

Note the updater setSeconds((s) => s + 1): the interval callback was created once, so it would always see the first render's seconds (0). The updater form always gets the latest value.

Cleanup: event listeners#

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

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

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

  return <p>This preview is {width}px wide. Resize your browser window to update it.</p>;
}
Result

Effects that depend on props#

When a dependency changes, React runs the previous effect's cleanup, then the effect again with the new value. This keeps the component in sync, for example with a document title:

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

export default function App() {
  const [unread, setUnread] = useState(3);
  const [title, setTitle] = useState("");

  useEffect(() => {
    document.title = unread > 0 ? `(${unread}) Inbox` : "Inbox";
    setTitle(document.title);
  }, [unread]);

  return (
    <div>
      <p>Document title is now: <strong>{title}</strong></p>
      <button onClick={() => setUnread(unread + 1)}>New message</button>{" "}
      <button onClick={() => setUnread(0)}>Mark all read</button>
    </div>
  );
}
Result

(This changes the title of the preview's own frame document, so the page shows it in the text rather than in your browser tab.)

Why effects run twice in development#

In development with <StrictMode> (which Vite and Next.js projects turn on), React mounts each component, immediately unmounts it, and mounts it again. Your effect runs, its cleanup runs, and the effect runs again. This is deliberate: if something breaks, your cleanup is missing or incomplete. It does not happen in production. (The previews on this site do not use Strict Mode, so effects run once here.)

You might not need an effect#

Effects are for synchronising with external systems. Many things beginners put in effects do not need one:

  • Calculating values from props or state: compute them during rendering instead.

    JSX
    // ❌ extra state and an extra render
    const [fullName, setFullName] = useState("");
    useEffect(() => setFullName(first + " " + last), [first, last]);
    
    // ✅ just calculate it
    const fullName = first + " " + last;
  • Responding to a user action (a click, a submit): put the code in the event handler, not in an effect that watches state.

  • Resetting state when a prop changes: give the component a key that changes (<Profile key={userId} />), and React creates a fresh instance.

Common mistakes#

Practice#

Practice exercise

Build a countdown that starts at a number chosen with buttons (10 or 30). It counts down once per second and stops at 0, showing "Time's up!". Changing the starting number restarts the countdown.

Show hint

Keep start and left in state. In an effect that depends on start, set left to start and create an interval that decreases left with the updater form, stopping at 0. Return a cleanup that clears the interval.

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

export default function App() {
  const [start, setStart] = useState(10);
  const [left, setLeft] = useState(10);

  useEffect(() => {
    setLeft(start);
    const id = setInterval(() => setLeft((s) => Math.max(s - 1, 0)), 1000);
    return () => clearInterval(id);
  }, [start]);

  return (
    <div>
      <button onClick={() => setStart(10)}>10 s</button>{" "}
      <button onClick={() => setStart(30)}>30 s</button>
      <p style={{ fontSize: 24 }}>{left > 0 ? `${left} s left` : "Time's up!"}</p>
    </div>
  );
}
Result

The interval keeps running at 0 (setting the same value, which React ignores) until the starting number changes or the component is removed; clearing it inside the updater would need a ref, covered in useRef.

Key takeaway#

Next: Fetching Data.

Finished this lesson?