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. useEffectEvent: Reading Fresh State Without Restarting the Effect
Section 5's fix for the interval's stale closure was to add count to the
dependency array — correct, but it means the interval is torn down and recreated
every single time count changes, resetting its timing. Sometimes that's
fine. Sometimes you specifically need an effect that keeps running uninterrupted
(connect once, start a timer once) while the code inside it still reads the
latest props and state, not whatever they were when the effect first ran.
That's what useEffectEvent (stable since React 19.2) is for.
import { useEffect, useEffectEvent, useState } from 'react';
function Counter({ label }: { label: string }) {
const [count, setCount] = useState(0);
const onTick = useEffectEvent(() => {
console.log(`${label}: ${count}`); // always reads the LATEST label and count
});
useEffect(() => {
const id = setInterval(() => {
onTick();
}, 1000);
return () => clearInterval(id);
}, []); // deliberately empty -- onTick is not "reactive," so it's not a dependency
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
A function created by useEffectEvent is called an Effect
Event. It can only be called from inside an effect (or another Effect Event),
it's deliberately excluded from dependency arrays — the hook's lint rule expects that
and won't ask you to add it — and inside its body, every prop and piece of state
always reflects the render that's currently visible on screen, not the render that
was active when the effect last ran. The interval in the example above is created
exactly once and never resets, yet onTick always logs the current
count and label — the stale-closure bug from Section 5 is
gone without paying the "restart the whole effect" cost.
You can't call one during render, and it can't return a cleanup function — it's purely "code that runs in response to something the effect triggered, but reads fresh values." Think of the split as: the effect's dependency array answers "when should this whole thing re-synchronize?"; the Effect Event answers "what's the current value of things I only need to read, not react to?"
7. 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.
Part 3 — Auto-save with useEffectEvent:
Back in the Stopwatch from Part 1. Add background auto-save that must never reset its own cadence, even though it needs to read state that changes constantly — the exact scenario Section 6 covers.
- Add a
sessionLabeltext input, state-bound, above the stopwatch controls. - Add a second effect that starts a
setIntervalfiring every 5 seconds, for the lifetime of the component — this effect's dependency array must stay empty; it should never tear down and recreate the interval. - Wrap the actual save logic in a
useEffectEvent-created function —localStorage.setItem('stopwatch-session', JSON.stringify({ sessionLabel, laps }))— and call it from inside the interval callback. - Prove it works: type into the label field and record a couple of laps while watching the console/localStorage — the saved snapshot should always contain the latest
sessionLabelandlaps, and the 5-second save cadence should never visibly reset no matter how often you type or click Lap.
If your instinct is to add sessionLabel and laps to the auto-save effect's dependency array "to be safe," resist it — that's precisely what would restart the 5-second timer on every keystroke. The whole point of wrapping the save logic in useEffectEvent is that it doesn't need to be a dependency to see current values.
8. 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.