Pitfalls: effects & closures
Endless loops, stale values, duplicate timers
Nearly every effect bug has one of four causes: the wrong list at the end, missing cleanup, an effect that should not exist, or a function holding stale values.
useEffect is the hook that eats the most time. The good news: it is the same four causes over and over.
1. The endless loop
// a) no list at all + setState
useEffect(() => {
setData(compute(x));
}); // draw -> effect -> draw -> …
// b) an object or array in the list
const filter = { active: true };
useEffect(() => { load(filter); }, [filter]); // filter is always new
// c) a function from the parent in the list
useEffect(() => { onLoad(); }, [onLoad]); // onLoad is defined inline Put a console.log in the effect and print the dependencies with it. Look at which one changes. In 90 % of cases it is an object, an array or a function created fresh on every pass.
function Parent() {
const load = () => fetch("/api"); // new on every pass
return <Child load={load} />;
}
function Child({ load }) {
useEffect(() => { load(); }, [load]);
} function Parent() {
const load = useCallback(() => fetch("/api"), []);
return <Child load={load} />;
}
// Or better: move the function into the effect
// if only the child needs it. 2. Chains of effects
useEffect(() => {
setFiltered(items.filter(matches));
}, [items]);
useEffect(() => {
setSorted([...filtered].sort(cmp));
}, [filtered]);
useEffect(() => {
setPage(sorted.slice(0, 20));
}, [sorted]); const page = useMemo(() => {
return items.filter(matches).sort(cmp).slice(0, 20);
}, [items]); Every effect that only turns state into state costs an extra draw and shows inconsistent data in between. Just compute instead.
3. Forgotten cleanup
Without cleanup you typically get:
setInterval -> after 10 switches, 10 timers are running
addEventListener -> handlers fire repeatedly, memory leaks
WebSocket / SSE -> connections stay open
IntersectionObserver -> watching nodes that no longer exist
fetch -> setState on a component that has left the page If your effect runs twice in development and something doubles up (two connections, duplicate rows), the cleanup is missing. StrictMode is the messenger here, not the culprit.
4. The effect that should not exist
// ❌ reacting to a click
useEffect(() => { if (sent) toast("Thanks!"); }, [sent]);
// ✅ belongs in the handler
// ❌ resetting state when a prop changes
useEffect(() => { setDraft(""); }, [userId]);
// ✅ <Editor key={userId} />
// ❌ telling the parent
useEffect(() => { onChange(value); }, [value]);
// ✅ call onChange where value actually changes
// ❌ deriving
useEffect(() => { setFull(first + " " + last); }, [first, last]);
// ✅ const full = first + " " + last; 5. Functions holding stale values
Every pass creates new functions – with the values from exactly that pass. If an old function stays stuck in a timer or a listener, it keeps working with the values of that moment forever. The term for this is Stale closureA function works with outdated values because it was created in an earlier pass and holds on to the old ones.→.
function Search({ onSearch }) {
const [text, setText] = useState("");
useEffect(() => {
const id = setTimeout(() => onSearch(text), 500); // text as of NOW
return () => clearTimeout(id);
}, [text, onSearch]); // both values belong in here
}
// It gets dangerous when somebody "tidies" the list down to []:
// then the timer calls onSearch("") forever – with the text from
// the very first pass. // eslint-disable-next-line react-hooks/exhaustive-deps is nearly always a hidden bug ticket. The legitimate exceptions are so rare that they deserve a comment explaining why.
For the case “I want to read the current value but not react to it” there is a hook on the way (useEffectEvent, still experimental). Until then: mirror the value into a ref, or restructure the effect.
Does it stick?
5 questions on this lesson. Wrong answers show up in your stats.
Now write it yourself
Three effects hand state along to each other. The result is eventually right, but it costs three extra draws and flickers. Replace the chain with a direct computation.
- □The display shows the three most expensive items, highest first
- □The file no longer contains
useEffect - □Only one piece of state is left: the raw data
import { useEffect, useState } from "react"; type Product = { name: string; price: number; active: boolean }; const PRODUCTS: Product[] = [ { name: "Chair", price: 80, active: true }, { name: "Table", price: 250, active: true }, { name: "Lamp", price: 40, active: false }, { name: "Shelf", price: 120, active: true }, { name: "Sofa", price: 900, active: true }, ]; export default function App() { const [products] = useState(PRODUCTS); const [activeOnes, setActiveOnes] = useState<Product[]>([]); const [sorted, setSorted] = useState<Product[]>([]); const [top, setTop] = useState<Product[]>([]); useEffect(() => { setActiveOnes(products.filter((p) => p.active)); }, [products]); useEffect(() => { setSorted([...activeOnes].sort((a, b) => b.price - a.price)); }, [activeOnes]); useEffect(() => { setTop(sorted.slice(0, 3)); }, [sorted]); return ( <div style={{ fontFamily: "system-ui", padding: 16 }}> <h3>Most expensive active products</h3> <ol> {top.map((p) => ( <li key={p.name}> {p.name}: {p.price} € </li> ))} </ol> </div> ); }