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.
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#
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#
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>
);
}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:
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>
);
}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#
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>;
}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:
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>
);
}(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
keythat 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
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>
);
}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?