Concept
The beginner framing: useEffect lets a component run some code after it renders, fetching data, setting up a subscription, manually touching the DOM.
The precise mental model: useEffect is not a generic "run this after render" hook, and it isn't a class-component lifecycle method in disguise. It's a synchronization primitive, a way to declare "this side effect (a connection, a subscription, a timer, a DOM measurement) needs to stay in sync with these specific values." You describe what it depends on; React decides when to re-run it by comparing the dependency array between renders.
useEffect(() => {
const connection = connectToRoom(roomId);
return () => connection.disconnect(); // cleanup
}, [roomId]); // dependencies- The effect function runs after the commit, after React has already updated the real DOM for this render.
- The dependency array is what React compares, render to render, to decide whether to re-run the effect.
- The returned cleanup function runs before the next effect (not just on unmount), undoing whatever the previous effect set up, so the next one can start clean.
useEffect(() => {const conn = connect(roomId);return () => conn.disconnect();}, [roomId]);
On mount there's no previous deps array to compare against, the effect always runs once after the first commit. React stores this render's deps array, ["general"], for next time.
"You might not need an Effect"
The single most common effect mistake is reaching for one when something simpler would do:
| Instead of an Effect... | Do this |
|---|---|
| Deriving a value from existing props/state inside an effect + extra state | Just compute it directly during render: const fullName = firstName + " " + lastName; |
| Resetting state when a prop changes | Give the component a different key, React remounts it, resetting all state for free (no effect needed) |
| Running logic in response to a specific user action (a click, a submit) | Put that logic directly in the event handler, it doesn't need to "watch" for a state change via an effect |
Chaining multiple setState calls across multiple effects to derive one final value | Compute the final value in one place during render, or in the one handler that triggers it |
An effect is for synchronizing with something outside React's rendering model, not for coordinating React's own state with itself.
Stale closures: the other half of the dependency array's job
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // closes over count from the render this effect was created in
}, 1000);
return () => clearInterval(id);
}, []); // ❌ empty deps, this effect (and its closure) is created ONCE, ever
return <button onClick={() => setCount((c) => c + 1)}>{count
function Counter() {const [count, setCount] = useState(0);useEffect(() => {const id = setInterval(() => {console.log(count); // closes over THIS render's count}, 1000);return () => clearInterval(id);}, []); // ❌ empty deps, runs once, everreturn <button onClick={() => setCount(c => c + 1)}>{count}</button>;}
Render 1: count = 0. Because deps is [], this effect runs exactly once, on mount, and never again. The arrow function handed to setInterval closes over THIS render's count binding, permanently.
Because the deps array is [], the effect runs exactly once, on mount, its callback is never recreated, so the count it closes over is frozen at whatever count was during that first render (0), forever, no matter how many times the component re-renders afterward. This is mechanically identical to a closure capturing a loop variable in plain JavaScript (see Closures), the fix is either to include count in the deps array (accepting a re-subscribe every change) or to read the latest value through a ref instead of the captured variable.
Try It
Predict the console output before checking the solution.
function Logger({ userId }) {
useEffect(() => {
console.log("subscribing to", userId);
return () => console.log("cleaning up", userId);
}, [userId]);
return null;
}
// Rendered with userId="a", then re-rendered with userId="b", then unmounted.Solution
subscribing to a (mount)
cleaning up a (userId changed: PREVIOUS effect's cleanup runs first)
subscribing to b (then the NEW effect runs)
cleaning up b (unmount: the most recent cleanup runs one final time)Cleanup isn't only an unmount thing, it runs immediately before the next effect whenever the dependencies change, in addition to the final time when the component unmounts entirely.
Implement It Yourself
Build a minimal effect scheduler that shows exactly how the dependency comparison and cleanup ordering work:
function createEffectRunner() {
let prevDeps = null;
let cleanup = null;
return function runEffect(effectFn, deps) {
const depsChanged =
prevDeps === null || deps.some((dep, i) => !Object.is(dep, prevDeps[i]));
if (depsChanged) {
if (cleanup) cleanup(); // tear down the PREVIOUS effect first
cleanup = effectFn() ?? null; // then run the new one
prevDeps = deps;
This is a faithful, simplified model of exactly what React's effect system does on every commit, compare deps with Object.is per element, run the previous cleanup if anything changed, then run the new effect.
Under the Hood
An effect's callback runs asynchronously relative to the render that scheduled it, specifically, after the browser has painted, which mirrors the macrotask/microtask distinction from The Event Loop: render and commit are synchronous work, but the effect callback is queued to run afterward, not blocking the paint. The stale-closure trap above is the exact same mechanism as Closures's classic "closure over a loop variable" bug, every render creates a brand-new function (including a brand-new effect callback), and whichever one React decided NOT to re-run keeps referencing the variables from the render it was created in.
Common Mistakes
1. Omitting a dependency the effect actually uses
useEffect(() => {
console.log(count); // reads count...
}, []); // ❌ ...but count isn't listed, stale closure guaranteedIf an effect's callback reads a prop, state, or derived value, that value belongs in the dependency array, omitting it (even "on purpose," to make the effect run only once) is the single most common source of stale-closure bugs. The eslint-plugin-react-hooks "exhaustive-deps" rule exists specifically to catch this.
2. A new object/array/function literal in the deps array every render
useEffect(() => {
doSomething(config);
}, [{ mode: "fast" }]); // ❌ a NEW object every render, "changes" on every comparisonObject.is compares by reference, an inline literal is a different reference every render, so this effect re-runs on every single render regardless of whether mode genuinely changed. Fix by depending on the primitive value(s) directly ([config.mode]) or memoizing the object with useMemo.
3. Fetching data in an effect with no cleanup or race-condition guard
useEffect(() => {
fetch(`/api/user/${userId}`).then((res) => res.json()).then(setUser); // ❌ no guard
}, [userId]);If userId changes again before the first request resolves, the first request's response can arrive after the second and overwrite it with stale data. Guard with a cleanup flag (or AbortController):
useEffect(() => {
let cancelled = false;
fetch(`/api/user/${userId}`).then((res) => res.json()).then((data) => {
if (!cancelled) setUser(data);
});
return () => { cancelled = true; };
}, [userId]);Best Practices
- Ask "is this actually synchronizing with something outside React?" before reaching for an effect, a value derivable from existing props/state doesn't need one.
- Let the ESLint exhaustive-deps rule guide the dependency array rather than fighting it, a suppressed lint warning here is almost always a future stale-closure bug.
- Always clean up what you set up, subscriptions, timers, and in-flight requests all need a cleanup function or a cancellation guard.
- Keep each effect focused on one synchronization concern, multiple unrelated effects, each with their own tight dependency array, are easier to reason about than one effect trying to do everything.
Performance Tips
useEffect's callback runs after paint, so it never blocks the user from seeing the updated UI, reach foruseLayoutEffectonly for the rare case where you must measure/mutate the DOM before the browser paints (e.g., avoiding a visible flicker), since it does block paint.- An effect that re-runs far more often than expected is almost always a dependency-array reference-stability problem (Common Mistake #2), not evidence that "effects are just expensive", profile the dependency array before optimizing the effect body itself.
