Module 4: Hooks and Lifecycles
useEffect: Synchronizing with the Outside World
What effects are for, how the dependency array really works, why cleanup exists, and how to tell when you shouldn't be using an effect at all.
useEffect is the hook people misuse the most, and almost always for the same reason: they learned it as "the thing that runs when the component mounts". That description is close enough to be dangerous.
A better description: an effect is how a component synchronizes with something outside React. A browser API, a timer, a WebSocket, a third-party chart library, a document title. Anything React doesn't control.
The shape#
import { useEffect, useState } from "react";
function PageTitle({ count }) {
useEffect(() => {
document.title = `Cart (${count})`;
}, [count]);
return <p>You have {count} items.</p>;
}
The first argument is a function (the effect). The second is an array of dependencies. React runs the effect after the component has rendered and the changes have been painted to the screen, never during render.
When it runs#
Don't think in terms of "mount" and "update". Think: the effect re-runs whenever a value it uses has changed since the last time it ran.
useEffect(() => { /* A */ }); // after every render
useEffect(() => { /* B */ }, []); // after the first render only
useEffect(() => { /* C */ }, [a, b]); // first render, then whenever a or b changed
React compares each dependency with the previous render's value using Object.is. If any differ, it runs the effect again.
The dependency array is not an optimization knob you tune. It's a declaration of what the effect reads. If your effect uses userId, then userId goes in the array. Always. The ESLint rule react-hooks/exhaustive-deps (already in the Vite template) checks this for you. When it complains, fix the code. Suppressing the warning is how stale-data bugs get in.
Cleanup#
An effect can return a function. React calls it before running the effect again and when the component unmounts.
function Clock() {
const [now, setNow] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(id); // cleanup
}, []);
return <p>{now.toLocaleTimeString()}</p>;
}
Without that cleanup, the interval keeps running after the component disappears, and still calls setNow on something that doesn't exist. And if the effect re-runs, you'd stack up multiple intervals.
Here's the sequence for an effect with dependencies:
render (id = 1) ──► effect runs (sets up for id 1)
render (id = 2) ──► cleanup runs (tears down id 1)
──► effect runs (sets up for id 2)
unmount ──► cleanup runs (tears down id 2)
Every setup gets exactly one matching teardown. The pattern is: whatever the effect starts, the cleanup stops.
StrictMode runs it twice#
In development, StrictMode mounts your component, immediately unmounts it, and mounts it again. So effects go setup → cleanup → setup. It's a deliberate stress test: if your effect is written correctly, running that extra pair changes nothing the user can see.
If you see something fire twice (two network requests, two subscriptions), the problem isn't that React is wrong. It's that the cleanup is missing or wrong. Fix the cleanup, don't remove StrictMode.
Subscribing to things#
function WindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
function onResize() {
setWidth(window.innerWidth);
}
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return <p>{width}px</p>;
}
Same recipe: subscribe in the effect, unsubscribe in the cleanup. That covers event listeners, WebSockets, observers, and map instances.
Fetching (the short version)#
function User({ id }) {
const [user, setUser] = useState(null);
useEffect(() => {
let ignore = false;
fetch(`/api/users/${id}`)
.then((res) => res.json())
.then((data) => {
if (!ignore) setUser(data);
});
return () => {
ignore = true; // if id changes before the response arrives, discard it
};
}, [id]);
return user ? <h2>{user.name}</h2> : <p>Loading</p>;
}
The ignore flag matters. Suppose the user clicks through profile 1, then quickly to 2. Request 1 might come back after request 2, and without the flag you'd show user 1's data on user 2's page. The cleanup marks the earlier request as stale. We'll do this properly, with errors and aborting, in module 7, and then move to a library that handles it all.
You might not need an effect#
Most effects I see in code reviews shouldn't exist. Ask yourself first:
Is it derived from props or state? Calculate it during render.
// Unnecessary effect: renders twice and can flash stale content
const [fullName, setFullName] = useState("");
useEffect(() => { setFullName(first + " " + last); }, [first, last]);
// Just compute it
const fullName = first + " " + last;
Is it a response to a user action? Put it in the event handler.
// Wrong: user clicked buy, but we do the work in an effect
useEffect(() => { if (purchased) sendReceipt(); }, [purchased]);
// Right
function handleBuy() {
buy();
sendReceipt();
}
Do you need to reset state when a prop changes? Use a key on the component instead.
An effect is for things that must happen because the component is showing, like keeping a connection open. If something happens because the user did something, it belongs in a handler.
Stale closures#
Effects close over the values from the render they were created in, exactly like event handlers do.
function Ticker() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1); // always sees count = 0
}, 1000);
return () => clearInterval(id);
}, []); // lint warns that count is missing
return <p>{count}</p>;
}
This counts to 1 and stops, because the interval callback closed over count from the first render. Adding count to the dependencies works but tears down and recreates the interval every second. The cleaner fix is the updater form, which doesn't need to read count at all:
setCount((c) => c + 1);
When an effect needs to read the latest value of something without re-running every time it changes, React 19.2 gives you useEffectEvent. It lets you pull a piece of non-reactive logic out of the effect:
import { useEffect, useEffectEvent } from "react";
function Chat({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification("Connected", theme); // always sees the latest theme
});
useEffect(() => {
const conn = createConnection(roomId);
conn.on("connected", onConnected);
conn.connect();
return () => conn.disconnect();
}, [roomId]); // theme changes don't reconnect
}
Don't reach for it often. Most of the time the updater form or a correct dependency array is enough.
Infinite loops#
useEffect(() => {
setItems([...items, newItem]); // changes items, which triggers the effect again
}, [items]);
An effect that sets state that's in its own dependency list loops forever. Same story with an object or array literal in the dependencies, since a new {} is created every render and never equals the previous one. Move it outside the component, or use primitives (user.id rather than user).
Where people get burned#
- Missing dependencies and silencing the lint rule.
- No cleanup for timers, listeners and subscriptions.
- Using an effect to derive state or to respond to a click.
- Putting
asyncstraight on the effect function. Effects must return nothing or a cleanup function, and an async function returns a promise. Define an async function inside and call it. - Object or function dependencies that are recreated each render.
- Fetching without handling out-of-order responses.
Practice#
- Build a component that sets
document.titlefrom a text input, and verify it updates on every keystroke. - Create a
useEffectthat logs "setup" and "cleanup" with the dependency value. Change the value and watch the order in the console, with StrictMode on. - Fix the
Tickerbug above using the updater form. - Create a user profile that fetches by id from
https://jsonplaceholder.typicode.com/users/1(change the id with buttons), with theignoreflag. Throttle the network in DevTools and click quickly between ids to see why the flag exists. - Find an effect in your own old code that just copies props into state and delete it.