1. useState in Depth
useState(initial) returns a pair: the current value, and a setter
function that schedules a re-render with a new value. The part that's easy to miss
from Week 1: state updates are asynchronous and can be batched —
calling the setter doesn't change the variable immediately in the same line of code.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // still the OLD value -- setCount hasn't re-rendered yet
}
return <button onClick={handleClick}>{count}</button>;
}
This gets genuinely dangerous when you call the setter more than once based on the
same stale count:
function handleClick() {
setCount(count + 1); // count is 0 here -> sets to 1
setCount(count + 1); // count is STILL 0 here -> sets to 1 again, not 2
}
The fix is the functional update form: pass a function to the setter, and React guarantees it receives the most recent state, even across multiple queued updates in the same event:
function handleClick() {
setCount(prev => prev + 1); // 0 -> 1
setCount(prev => prev + 1); // 1 -> 2, correctly
}
If your new state depends on the previous state in any way, use the functional form: setX(prev => ...). If it's a completely new value unrelated to the old one, setX(newValue) is perfectly fine.
State is also treated as immutable — never mutate an object or array in place and pass it back to the setter; React compares state by reference, so a mutated-in-place object looks identical to React and won't trigger a re-render.
const [user, setUser] = useState({ name: 'Ada', age: 30 });
// Wrong -- mutates the existing object, React sees the same reference
user.age = 31;
setUser(user);
// Right -- creates a new object, React sees a new reference and re-renders
setUser({ ...user, age: 31 });
const [items, setItems] = useState([1, 2, 3]);
// Wrong
items.push(4);
setItems(items);
// Right
setItems([...items, 4]);
2. useEffect: Synchronizing with the Outside World
Everything inside a component's function body should be about computing what to
render. The moment you need to touch something outside React — the browser
DOM directly, document.title, a timer, a WebSocket, or (starting Week 8)
a network request — that's an effect.
import { useState, useEffect } from 'react';
function TitleUpdater() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Clicked ${count} times`;
}, [count]);
return <button onClick={() => setCount(c => c + 1)}>Click me</button>;
}
useEffect takes a function to run after React has committed the
render to the DOM, plus a second argument — the dependency array —
that controls when it re-runs. Here, the effect re-runs only when count
changes, keeping the document title in sync without running on every unrelated render.
useEffect
Computing a value from props/state (like a filtered list, or a total) belongs directly in the render body, not in an effect — see Week 3's useMemo for when that computation is expensive enough to matter. useEffect is specifically for synchronizing with something React doesn't manage.
3. Dependency Arrays — What They Actually Compare
The dependency array tells React: "re-run this effect only if one of these values
changed since the last render." React compares each entry with
Object.is — the same reference-equality check state updates use. Three
shapes, three very different behaviors:
useEffect(() => {
console.log('runs after EVERY render');
}); // no array at all
useEffect(() => {
console.log('runs once, after the first render only');
}, []); // empty array -- nothing to depend on
useEffect(() => {
console.log('runs after the first render, and again whenever userId changes');
}, [userId]); // runs when a listed value changes
A common trap: including an object or array literal created fresh on every render as a dependency. Because it's a new reference every time, the effect thinks something changed and re-runs on every single render — even if the object's contents look identical.
function SearchResults({ query }: { query: string }) {
const options = { caseSensitive: false }; // NEW object every render
useEffect(() => {
search(query, options);
}, [query, options]); // options never "equals" its previous value -> runs every render
}
The fix here is usually to depend only on the primitive values the effect actually
reads (query, options.caseSensitive), or to move the object
construction inside the effect itself where it isn't a dependency at all.
4. Cleanup Functions
If an effect sets something up — a timer, an event listener, a subscription — it needs to tear that thing down, or you'll leak it every time the effect re-runs or the component unmounts. Return a function from the effect, and React calls it right before the effect runs again, and once more when the component unmounts.
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
return () => clearInterval(id); // cleanup -- runs on unmount
}, []); // empty array -- set up the interval once
return <p>{seconds}s elapsed</p>;
}
Without that clearInterval, navigating away from this component would
leave the interval running forever in the background, still calling
setSeconds on a component that no longer exists — exactly the kind of
leak that's invisible in a five-minute demo and very visible in a real app after an
hour of use.
useEffect(() => {
function handleResize() {
console.log(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
5. Common Pitfalls: Stale Closures & Infinite Loops
A stale closure happens when an effect (or any function) captures a variable's value at the time it was created, and that value never gets refreshed because the effect wasn't told to re-run:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log(count); // always logs 0 -- this closure captured count=0 forever
}, 1000);
return () => clearInterval(id);
}, []); // empty array -- effect never re-runs, so `count` inside it never updates
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
The interval callback closed over count from the render where the effect
first ran, and because the dependency array is empty, that effect (and its closure)
is never recreated. Two fixes: add count to the dependency array (the
interval resets every time count changes), or use the functional update
form inside a ref-based pattern if you specifically need the interval to keep running
uninterrupted — the first option is almost always what you want.
The other classic bug is the infinite loop: an effect that updates state it also depends on, without a condition that eventually stops it.
function BadCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1); // triggers a re-render...
}, [count]); // ...which changes count...which reruns the effect...forever
return <p>{count}</p>;
}
Every render sets state, every state change re-runs the effect, every effect run sets state again. React doesn't stop this for you — it'll happily re-render as fast as it can until the tab hangs. If an effect sets state, double check whether it truly needs to depend on that same state, or whether the update belongs somewhere else entirely (an event handler, or removed altogether if it can be computed directly during render).
6. Hands-on Exercise
Build a stopwatch with lap times, then fix an injected race condition
Practice effects, cleanup, and functional state updates by building something that would break immediately if you got any of them wrong.
Part 1 — Stopwatch:
- Build a
Stopwatchcomponent withelapsedMsstate, and Start/Pause/Reset buttons. - Use
setIntervalinside auseEffectthat only runs while the stopwatch is "running" (make the effect depend on aisRunningboolean), updatingelapsedMswith the functional setter form every 10ms. - Properly clean up the interval whenever
isRunningflips tofalseor the component unmounts — no leaked intervals. - Add a "Lap" button that appends the current
elapsedMsto alaps: number[]state array, rendered as a list, most recent first.
Part 2 — Fix the race condition:
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = useState<{ name: string } | null>(null);
useEffect(() => {
fetchUser(userId).then(data => setUser(data)); // BUG: no cleanup
}, [userId]);
return <p>{user?.name ?? 'Loading...'}</p>;
}
If userId changes quickly (e.g. clicking through a list fast), an older, slower request can resolve after a newer one and overwrite it with stale data. Add a cleanup-based "ignore" flag so a request only calls setUser if its effect is still the current one when it resolves.
Declare let ignore = false; at the top of the effect, set ignore = true in the cleanup function, and check if (!ignore) before calling setUser inside the .then(). You'll see this exact pattern generalized by a library in Week 8, so it's worth understanding by hand first.
7. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does calling setCount(count + 1) twice in a row not add 2 to the count?
Why does calling setCount(count + 1) twice in a row not add 2 to the count?
Both calls close over the same stale count value from the current render — neither call sees the effect of the other, since state doesn't update until the next render. Using the functional form, setCount(prev => prev + 1), fixes it because React guarantees each functional update sees the result of the previous queued update.
Q2
What's the practical difference between an empty dependency array [] and no array at all?
What's the practical difference between an empty dependency array [] and no array at all?
[] tells React there's nothing to compare, so the effect runs exactly once, after the first render. Omitting the array entirely means React has nothing to check against at all, so the effect runs after every single render — almost never what you actually want.
Q3
What does the function returned from a useEffect callback do, and when does React call it?
What does the function returned from a useEffect callback do, and when does React call it?
It's the cleanup function, used to undo whatever the effect set up (clear a timer, remove a listener, cancel a subscription). React calls it right before running the effect again on a subsequent render, and one final time when the component unmounts — without it, anything the effect set up keeps running or listening forever, even after the component is gone.
Q4
Why does an effect that calls setCount(count + 1) while depending on [count] cause an infinite loop?
Why does an effect that calls setCount(count + 1) while depending on [count] cause an infinite loop?
Each run of the effect changes count, which is exactly what the dependency array is watching, so the changed value re-triggers the same effect — which changes count again, forever. React has no built-in guard against this; breaking the cycle requires either removing the dependency, adding a stopping condition, or moving the update to something that isn't itself watching the value it changes.