useEffect — and when not to use it
- Explain that effects exist to sync with systems outside React, like a socket, timer, or the network
- Write a subscription effect whose cleanup and deps reconnect cleanly when a prop like roomId changes
- Predict when an effect runs relative to render, and the stale value an empty-deps interval reads
- Fix a fetch effect's race and cancel stale requests by aborting the previous request with AbortController in cleanup, telling that AbortError apart from a real failure
Read
Don't derive state in an effect
FlagRule of thumb: if you can compute it during render, don't put it in an Effect.
// ❌ Don't: derive state with an Effect
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(first + " " + last);
}, [first, last]);
// ✅ Do: just compute it during render
const fullName = first + " " + last;
The Effect version renders once with a stale value and then re-renders. The plain version is correct on the very first render and does no extra work.
Effects that start something must clean it up
FlagEffects need cleanup whenever they start something that must later be stopped: a subscription, a timer, an event listener. The cleanup runs before the next effect and on unmount.
useEffect(() => {
const conn = createConnection(roomId);
conn.connect();
return () => conn.disconnect(); // runs on roomId change + on unmount
}, [roomId]);
Forget the cleanup and you leak a connection every time roomId changes. The dependency
array is a claim: "re-run this effect only when these values change."
In development, StrictMode (React 18+) checks this for you: on mount it runs one extra setup + cleanup cycle, so you'll see connect → disconnect → connect. With a correct cleanup that's invisible to the user; without one, you'd see two live connections straight away.
Fetching in an effect — guard races and cancel stale requests
FlagFetching inside an Effect has two classic hazards:
- Race conditions — a slow earlier request resolves after a newer one and overwrites its results.
- Requests nobody needs — the query changed or the component unmounted, but the old request keeps running, using bandwidth and server time for a response you'll throw away.
Guard both with AbortController:
useEffect(() => {
const controller = new AbortController();
fetchResults(query, { signal: controller.signal })
.then((results) => {
setResults(results);
setError(null);
})
.catch((err) => {
if (err.name === "AbortError") return; // we cancelled it in cleanup: expected
setError(err); // a real failure: put it on screen
});
return () => controller.abort();
}, [query]);
Don't throw err inside that .catch to "surface" a real error. A throw there only rejects
the promise chain, which ends as an unhandled promise rejection in the console; it never
reaches React, so an error boundary can't catch it. Put the error in state and render it.
This is the exact bug you fix in the challenge at the end of this unit. In real apps, prefer a data-fetching library (TanStack Query) or your framework's data loading, which handle this for you — but understand why it's needed.
Quiz
Fill in the blank
Complete the subscription effect: subscribe on mount, re-subscribe when roomId changes, and clean up to avoid leaks.
FlaguseEffect(() => { const conn = createConnection(roomId); conn.connect(); return () => conn.(); }, []);
Complete the search effect so a query change or an unmount cancels the request still in flight.
FlaguseEffect(() => { const controller = new (); fetch(`/api/search?q=${encodeURIComponent(query)}`, { : controller.signal }) .then((res) => res.json()) .then(setResults) .catch((err) => { if (err.name !== "AbortError") setError(err); }); return () => controller.(); }, [query]);
Predict the output
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}// Production build (no StrictMode double-calls)
function App() {
const [n, setN] = useState(0);
console.log('render', n);
useEffect(() => {
console.log('effect', n);
});
return <button onClick={() => setN(n + 1)}>{n}</button>;
}Challenge
Fix the search-as-you-type effect
FlagSearchUsers fetches results whenever the query changes. It works for a single search,
but it has three bugs that show up the moment requests overlap or fail:
- Race condition: if an earlier (slower) request resolves after a newer one, the stale results overwrite the fresh ones.
- No cancellation: in-flight requests keep running after the query changes or the component unmounts, wasting network and server work on responses nobody will use.
- Silent failures: a request that fails is never caught. The user sees nothing, and the console gets an unhandled promise rejection.
Show a failed search in an element with role="alert". A request you cancelled is not a
failure, so it must not show up there.
Bonus: discuss why a library like TanStack Query removes the need to write this by hand.
Your task
Edit SearchUsers.tsx so the tests pass:
- Typing a query shows that query's results
- A slow earlier response never overwrites a newer query's results
- The in-flight request is aborted when the query changes or the component unmounts
- A failed search shows an error in a
role="alert"element; a request you cancelled doesn't