In the last week you learned what state is and how to update it. This week is about understanding what actually happens when you do. You'll learn how React takes a state update and turns it into something visible on screen, and more practically why state sometimes seems to behave in unexpected ways when you read it right after setting it.
This is a more conceptual week than the previous one. The payoff is a mental model that makes confusing bugs immediately obvious, instead of requiring twenty minutes of console.log archaeology.
https://www.youtube.com/watch?v=ARWX1XdghLk
Every update you see on a React screen goes through three phases. The React docs use a restaurant analogy that's genuinely useful: your components are chefs in a kitchen, and React is the waiter.

A render is triggered in exactly two ways:
The initial render — when your app first loads and React calls your root component for the first time to build the initial UI.
A state update — any time a component calls a setter function from useState, React queues a new render for that component.
That's the complete list. React doesn't re-render components on a timer, in response to network activity, or for any other reason on its own. The only way to get new content on screen is through one of these two triggers. This is worth internalising: if your UI isn't updating, something is either not calling a setter, or the setter isn't being reached.
// Trigger: initial render
const root = createRoot(document.getElementById('root'));
root.render(<App />);
// Trigger: state update
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
// Every click calls setCount, which triggers a new render
);
}
"Rendering" in React means React calling your component function. That's it — rendering is just a function call.
On the initial render, React calls the root component. On subsequent renders, React calls the specific component whose state changed. If that component returns other components in its JSX, React calls those too, recursively, until it has worked through the full tree and knows exactly what the UI should look like.
function App() {
return <ProductPage />; // React calls ProductPage next
}
function ProductPage() {
return (
<div>
<ProductImage /> // React calls ProductImage
<ProductDetails /> // React calls ProductDetails
</div>
);
}
The output of this phase is a new description of what the UI should look like: a tree of React elements. React holds this in memory and compares it to the previous description.
<aside> ⚠️
Warning
Your component function must be a pure function during rendering — meaning the same inputs always produce the same output, and it doesn't change anything outside of itself. No modifying external variables, no random numbers, no Date.now() called directly in the function body outside of event handlers. If you break this rule, bugs become extremely hard to trace.
React's Strict Mode (enabled by default in development with Vite and Create React App) deliberately calls your component function twice per render to help surface accidental side effects. If something runs twice when you expect it to run once during development, that's why.
</aside>
Once React has the new description of the UI from the render phase, it compares it to the current DOM and applies only what changed. This comparison and minimal update is the "commit" phase.
A critical detail the React docs highlight: React only touches DOM nodes that actually changed. If you type into an input field and a parent component re-renders for an unrelated reason, your input won't be wiped — React sees that the <input> element is in the same position with the same attributes, and leaves it alone.
// This component re-renders every second with a new `time` value
function Clock({ time }) {
return (
<>
<h1>{time}</h1>
<input placeholder="Type something..." />
</>
);
}
Even though the whole Clock component re-renders every second, React only updates the text content of <h1>. The <input> and anything you've typed into it are untouched, because nothing about the <input> changed between renders.
After React updates the DOM, the browser repaints the screen. This is handled entirely by the browser and React's job is done at the commit phase. You may sometimes see this referred to as "browser rendering," but the React docs call it "painting" to distinguish it from React's own render phase.
So the full sequence, end to end, looks like this:
State update → [Trigger] → React calls your function → [Render]
→ React diffs and updates the DOM → [Commit] → Browser repaints → [Paint]
<aside> ⌨️
Hands On
Open React DevTools in your browser (install the extension if you haven't). Create a simple counter component and click the button a few times. In the DevTools "Components" tab, watch the state value update in real time. Then open the "Profiler" tab and record a few clicks — you'll see each render logged as a flame chart. This makes the render cycle visible rather than abstract.
</aside>
Here's the concept that causes the most confusion for developers new to React, and understanding it will save you real debugging time.
When React renders your component, it takes a snapshot of the current state values and hands them to your function. For the entire duration of that render, including all event handlers attached to that render's JSX, the state values are fixed. They are constants, not live references to some external value React is updating in the background.
The React docs put it well: state "lives" outside your component, on a shelf in React's memory. When React calls your component, it reads from that shelf and gives you a frozen copy for the duration of the render.
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
console.log('Before:', count); // 0
setCount(count + 1);
console.log('After:', count); // still 0 — the snapshot hasn't changed
}
return <button onClick={handleClick}>Count: {count}</button>;
}
setCount doesn't update count in place. It tells React "schedule a re-render, and use this new value next time." The current execution — this render's event handler — keeps using its snapshot.
A useful technique from the React docs: when reasoning about what a handler will do, mentally substitute the state variable with its current value.
function Counter() {
const [count, setCount] = useState(0); // count = 0
return (
<button onClick={() => {
setCount(count + 1); // setCount(0 + 1) → queues 1
setCount(count + 1); // setCount(0 + 1) → queues 1 again
setCount(count + 1); // setCount(0 + 1) → queues 1 again
}}>
+3
</button>
);
}
Substituting count with 0: all three calls are setCount(0 + 1). React will queue 1 three times and end up with 1, not 3. The snapshot doesn't update between the calls, because it's fixed for this render.
This is exactly why you need functional updates (setCount(n => n + 1)) when the new value depends on the old one (as covered earlier). The updater function receives the most recently queued value, not the snapshot.
<aside> ⌨️
Hands On
Reproduce this yourself. Create a counter with a "+3" button that calls setCount(count + 1) three times. Verify it only goes up by 1. Then change all three calls to setCount(n => n + 1) and verify it goes up by 3. The difference is exactly the snapshot behaviour at work.
</aside>
This is where the snapshot model becomes practically important. The snapshot doesn't just cover synchronous code in an event handler: it's captured for any code that runs as part of that render's event handler, even if it runs later.
function NotificationForm() {
const [recipient, setRecipient] = useState('Alice');
const [message, setMessage] = useState('');
function handleSend(e) {
e.preventDefault();
// Simulate a delayed send (e.g., waiting for a network response)
setTimeout(() => {
alert(`Sent "${message}" to ${recipient}`);
// This will always show the values AT THE TIME of the click,
// not whatever is in the inputs when the alert fires
}, 3000);
}
return (
<form onSubmit={handleSend}>
<select value={recipient} onChange={(e) => setRecipient(e.target.value)}>
<option value="Alice">Alice</option>
<option value="Bob">Bob</option>
</select>
<input
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Message"
/>
<button type="submit">Send</button>
</form>
);
}
Try clicking "Send" with recipient set to 'Alice', then immediately switch the dropdown to 'Bob' before the 3-second timeout fires. The alert still says 'Alice'. The handleSend function closed over the snapshot from when it was created — recipient was 'Alice' in that render, and that's what the setTimeout callback sees.
This behaviour is actually a feature. Your event handlers working with a consistent snapshot means you don't have to worry about the UI changing mid-flight and corrupting the data you're working with.
<aside> 💡
Info
This snapshot behaviour is why React event handlers are described as reading from a "frozen" state. The React docs explain it as: every render has its own event handlers, and every event handler "sees" only the state values from its own render. The current state on screen may well have moved on, but the handler that's executing is still using its original snapshot.
</aside>
The snapshot model is a feature, except when you forget about it and it becomes a bug. "Stale state" is the name for when you read from a snapshot that's outdated relative to what you intended.
The most common form is the multiple-updates-in-one-handler problem you've already seen. But it also shows up in more subtle ways.
This pattern catches developers out regularly:
function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
async function handleSearch(e) {
const currentQuery = e.target.value;
setQuery(currentQuery);
// ❌ Stale: `query` here is from the snapshot, not the value just set
const data = await fetchResults(query);
setResults(data);
}
return (
<input onChange={handleSearch} value={query} />
);
}
After setQuery(currentQuery), the state query in the current snapshot still holds the old value. The await fetchResults(query) call is using stale data. The fix is to use the local variable that you know has the right value:
async function handleSearch(e) {
const currentQuery = e.target.value;
setQuery(currentQuery);
// ✅ Use the local variable, not the stale snapshot
const data = await fetchResults(currentQuery);
setResults(data);
}
The rule of thumb: if you need to use a value both to update state and for some other operation in the same handler, store it in a local variable first and use that.
When you're confused about why a value is "wrong," walk through it with substitution:
Most stale state bugs become obvious once you consciously apply these questions.
You already met batching in Week 6, but it's worth revisiting with a clearer picture of the render cycle behind it.
React doesn't immediately re-render every time you call a setter. Instead, it waits until all code in the current event handler has finished running, then processes all queued state updates together and performs a single re-render. This grouping is called batching.
function OrderForm() {
const [items, setItems] = useState([]);
const [total, setTotal] = useState(0);
const [step, setStep] = useState('cart');
function handleCheckout() {
setItems([]); // queued — no re-render yet
setTotal(0); // queued — no re-render yet
setStep('confirm'); // queued — no re-render yet
// React re-renders once here, with all three updates applied
}
// ...
}
Without batching, calling three setters would trigger three separate renders. With batching, React waits for the handler to finish, applies all three updates, and produces one render. The result on screen is consistent (you never see a half-updated state).
The React docs use a waiter analogy: a good waiter doesn't run to the kitchen after every item you order. They wait until you've finished, take the whole order at once, and process it in one trip.
After the render phase produces a new UI description, React needs to figure out what actually needs to change in the DOM. The process of comparing the previous and new output to determine the minimum set of changes is called reconciliation.
React walks both trees — the previous render's output and the new one — and compares them node by node. For each element it finds, it asks: is this the same type of element as before, in the same position? If yes, React updates the existing DOM node with any changed props. If the type changed, React tears down the old node and builds a new one from scratch.
// First render
<div>
<p>Hello</p>
<button>Click me</button>
</div>
// Second render — only the text changed
<div>
<p>Hello world</p> {/* React updates this text node */}
<button>Click me</button> {/* React leaves this alone */}
</div>
React sees the <div>, <p>, and <button> are all in the same positions with the same types — so it reuses the existing DOM nodes and only updates the text content of the <p>. It doesn't rebuild the whole tree.
If the element type at a position changes, React discards the old DOM node entirely and creates a new one. This includes any child state inside it: