The previous lesson showed what effects are for. This one is about writing them correctly, which mostly means getting the dependency array right and pairing every setup with a cleanup. Most "weird effect" bugs (stale values, infinite loops, listeners piling up) come from these two things.
Dependencies are not a choice
Think of an effect as a statement of what it synchronises: "keep the connection in step with roomId". The dependency array is not a performance dial that you tune; it must list everything from the component that the effect reads and that can change: props, state, and any variable calculated from them.
An effect that forgets a dependency still runs, but with a stale snapshot:
import { useEffect, useState } from "react";
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("effect sees count =", count);
}, []); // wrong: count is used but not listed
return <button className="add" onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
export default App;Count: 3
CONSOLE
effect sees count = 0
effect sees count = 0The screen shows 3, but the effect only ran at the start (twice because of StrictMode), when count was 0. Add count to the array and it reports every change. The exhaustive-deps lint rule from the React hooks lint plugin catches this for you (the Vite template turns on only the rules-of-hooks rule, so add this one to your lint setup), and you should treat its warnings as bugs. If the rule asks for a dependency that you believe you do not want, the answer is almost never to silence the rule; it is to change the code (see below).
Cleanup runs before the next effect
When dependencies change, React does not just run the effect again. The order is:
- Run the cleanup of the previous effect, with the old values.
- Run the new effect, with the new values.
When the component is removed, only the cleanup runs. Here is a pretend chat connection that depends on the room:
import { useEffect, useState } from "react";
function ChatRoom({ roomId }) {
useEffect(() => {
console.log("connect to " + roomId);
return () => console.log("disconnect from " + roomId);
}, [roomId]);
return <p>Welcome to the {roomId} room!</p>;
}
function App() {
const [roomId, setRoomId] = useState("general");
return (
<div>
<select value={roomId} onChange={e => setRoomId(e.target.value)}>
<option value="general">general</option>
<option value="music">music</option>
<option value="travel">travel</option>
</select>
<ChatRoom roomId={roomId} />
</div>
);
}
export default App;general
music
travel
Welcome to the travel room!
CONSOLE
connect to general
disconnect from general
connect to general
disconnect from general
connect to music
disconnect from music
connect to travelReading the log: after StrictMode's connect/disconnect/connect for general at the start, switching to music printed "disconnect from general" then "connect to music", and switching to travel disconnected from music before connecting. The cleanup used the old roomId because it belongs to the effect that created it. That is why cleanup can reliably undo exactly what its own setup did.
Write the cleanup as the mirror image of the setup:
| Setup | Cleanup |
|---|---|
addEventListener |
removeEventListener (same function) |
setInterval, setTimeout |
clearInterval, clearTimeout |
| open a connection or subscription | close it, unsubscribe |
| start a fetch | cancel or ignore its result (next lessons) |
| show a modal or lock scrolling | restore the previous state |
If an effect has nothing to undo, it needs no cleanup.
Objects and functions as dependencies
React compares dependencies with Object.is. For numbers and strings that means equal values. For objects, arrays and functions it means the same object in memory. A new object is created on every render, so it is always "different":
import { useEffect, useState } from "react";
function App() {
const [count, setCount] = useState(0);
const options = { theme: "dark" }; // a new object on every render
useEffect(() => {
console.log("effect ran, options.theme =", options.theme);
}, [options]); // changes identity on every render
return <button onClick={() => setCount(count + 1)}>Re-render ({count})</button>;
}
export default App;Re-render (1)
CONSOLE
effect ran, options.theme = dark
effect ran, options.theme = dark
effect ran, options.theme = darkThe effect ran for the first render (twice, StrictMode) and then again after the single click, although options never changed in content. In real code this produces effects that reconnect, refetch or reset on every keystroke. The fixes, from best to worst:
- Move the object inside the effect, if only the effect uses it. The effect then depends on the primitive values it actually reads.
- Move it outside the component, if it never depends on props or state (a module-level constant is created once).
- Depend on the primitive pieces:
[options.theme], not[options]. - Memoise it with
useMemo(for objects) oruseCallback(for functions), covered in the memoization lesson. It is a fix, not a first choice.
import { useEffect, useState } from "react";
const DEFAULTS = { theme: "dark" }; // outside the component: one object, ever
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
const local = { size: 10 }; // inside the effect: not a dependency
console.log("effect ran:", DEFAULTS.theme, local.size);
}, []); // nothing reactive is read
return <button onClick={() => setCount(count + 1)}>Re-render ({count})</button>;
}
export default App;Re-render (1)
CONSOLE
effect ran: dark 10
effect ran: dark 10Now clicking the button does not run the effect again. A function declared in the component has the same problem as an object (new each render), and the same cures apply: move it into the effect, outside the component, or wrap it in useCallback.
Removing a dependency with an updater function
Sometimes you really only want to react to a timer, but the callback needs the latest state. Instead of listing the state (which would restart the timer every tick), pass an updater function to the setter, which receives the newest value:
import { useEffect, useState } from "react";
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(c => c + 1); // no need to read count
}, 1000);
return () => clearInterval(id);
}, []); // runs once: the interval is never restarted
return <p>{count} seconds</p>;
}
export default App;If the effect read count directly (setCount(count + 1)), it would need count as a dependency, and the interval would be torn down and recreated on every tick. Updater functions are the cleanest way out. When an effect must read the latest value of something without re-running (rare), React offers an experimental useEffectEvent; for now, restructure to avoid it.
Infinite loops
An effect that sets state it also depends on, with nothing to stop it, loops forever:
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // render, effect, set state, render, effect, ...
}, [count]);Each state change triggers a render, which triggers the effect, which changes the state again. React stops after a large number of nested updates and throws "Maximum update depth exceeded". The usual causes:
- setting state in an effect with no dependency array (the effect runs after every render),
- an object or array dependency created during render (new each time),
- an effect that "derives" state from other state; calculate it during rendering instead.
The cure is to ask whether this should be an effect at all. See the next-but-one lesson, You might not need an effect.
Several effects instead of one
Each effect should do one job and have its own dependencies. Combining unrelated work in one effect forces it to re-run whenever any of its dependencies changes:
// Better: two effects, each with exactly its own dependencies
useEffect(() => { logVisit(pageId); }, [pageId]);
useEffect(() => { document.title = title; }, [title]);A checklist for every effect
- What is this effect synchronising with? (Say it in words.)
- Which props, state and derived values does it read? Those are the dependencies.
- What does it start that must be stopped? Write the cleanup.
- Does it still work if it runs, stops and runs again (StrictMode)?
- Is it really needed, or can the work be done during render or in a handler?
Common mistakes
- Leaving out dependencies to "stop the effect from running", then reading stale values.
- Silencing the exhaustive-deps warning with a comment instead of fixing the cause.
- Putting an object, array or function created during render in the dependencies.
- Forgetting the cleanup, or writing one that does not exactly undo the setup (removing a different function from
removeEventListener). - Setting state inside an effect on every run, creating a loop.
- Expecting the cleanup to see new values; it sees the values of its own render.
Exercise
- Add a "Pause" button to the ticking clock from the previous lesson, with
runningstate as a dependency, and make sure the interval is cleared correctly. - Add a
keydownlistener onwindowthat shows the last key pressed; list the right dependencies and verify in the Console that only one listener exists. - Change the
optionsexample so that the effect runs only whenoptions.themechanges. - Build a
ChatRoomeffect that depends on bothroomIdandserverUrland print the connect and disconnect messages as you change each one.
Key points
- Dependencies are not a choice: list every prop, state value and variable derived from them that the effect reads, and let the lint rule check.
- Before an effect runs again, React runs the previous effect's cleanup with the old values, then runs the effect with the new ones.
- Objects, arrays and functions created during render are new every time, so as dependencies they make the effect run on every render; move them inside the effect or out of the component.
- To read the latest state without depending on it, use an updater function such as setCount(c => c + 1).