useEffect Explained
Running side effects after render, controlling when they re-run with the dependency array, and cleaning them up correctly.
3 min read
useEffect runs code after React has rendered and committed a component to the DOM — for anything that reaches outside of React itself: fetching data, subscribing to an external event source, manually working with a DOM node, or setting up a timer.
import { useEffect, useState } from "react";
function Clock() {
const [time, setTime] = useState(new Date());
useEffect(() => {
const id = setInterval(() => setTime(new Date()), 1000);
return () => clearInterval(id);
}, []);
return <p>{time.toLocaleTimeString()}</p>;
}The dependency array controls when it re-runs
The second argument to useEffect is what makes it manageable, and it has three distinct behaviors depending on what you pass:
useEffect(() => { /* ... */ }); // runs after every render
useEffect(() => { /* ... */ }, []); // runs once, after the first render only
useEffect(() => { /* ... */ }, [userId]); // runs after the first render, and again whenever userId changesAn empty array means "no dependencies, so nothing this effect reads ever changes" — hence it only needs to run once. A populated array tells React to compare each listed value against its previous value on every render, and re-run the effect only if one of them changed.
Every reactive value the effect uses belongs in the array
A common bug is omitting a value the effect actually reads:
function SearchResults({ query }) {
useEffect(() => {
fetchResults(query).then(setResults);
}, []); // Bug: missing `query` — this never re-fetches when query changes
}The effect closes over query from the render it was created in, so with an empty array it keeps using the first render's query forever. The fix is to include everything the effect reads that can change between renders — [query] here — which is exactly what the eslint-plugin-react-hooks "exhaustive-deps" rule checks for automatically.
Cleanup functions
If the function you pass to useEffect returns another function, React treats that as cleanup — it runs before the effect runs again, and once more when the component unmounts:
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal }).then(/* ... */);
return () => controller.abort();
}, [url]);This is what prevents a very common class of bug: a subscription, timer, or in-flight request from a previous render (or from a component that's no longer even mounted) still running and trying to update state that no longer exists.
Not everything belongs in an effect
A frequent overuse of useEffect is deriving one piece of state from another:
// Unnecessary — an extra render, an extra state variable, an extra effect
useEffect(() => {
setFullName(firstName + " " + lastName);
}, [firstName, lastName]);If a value can be calculated directly from props or state during render, just calculate it during render — const fullName = firstName + " " + lastName; — with no useEffect and no extra state at all. Reach for useEffect specifically for synchronizing with something outside React, not for computing derived values.
Next up: useRef, for the cases where you need to persist a value across renders without it ever triggering a re-render.