State and Re-renders
Props come from outside. But some data is born inside a component: is the dropdown open, what has the user typed, which tab is selected. That's state, and it's where React goes from "template library" to "the thing running your app." It's also where the first real confusion lives, so this phase moves slowly and names every trap.
Why a normal variable can't work
The obvious attempt:
The variable really does increment - the console proves it. The screen never changes, for two separate reasons, and understanding them is understanding React state:
- Nothing tells React to redraw. Assigning to a local variable is invisible to React. No re-render happens, so the DOM keeps showing the description from the last render.
- The variable wouldn't survive anyway. A re-render means calling
Counteragain. Every call creates a freshlet count = 0. Local variables have the lifespan of one function call; your UI needs memory that outlives the call.
State solves exactly these two problems: it's memory that lives outside your function between calls, plus a way of changing it that notifies React.
useState: memory plus a doorbell
import from 'react';
What just happened: useState(0) registers a slot of memory with React (initial value 0) and
returns a pair: the current value and a setter function. Click the button and setCount(1) does two
things - stores 1 in React's slot, and schedules a re-render. React calls Counter again;
this time useState returns the stored 1; the new description says "Clicked 1 times"; React
patches the text. The loop from phase 1, now with memory:
📝 Terminology: useState is a hook - a function that hooks your component into a React
feature (here: persistent memory). Hooks have two hard rules: only call them inside components
(or other hooks), and only at the top level - never inside an if, a loop, or a callback. The
reason is mundane: React identifies each state slot by the order the hooks are called in. A hook
inside an if makes the order change between renders, and every slot after it silently gets the
wrong value. Phase 8 shows the error message this produces.
State is a snapshot
Here's the trap that catches everyone in week one:
You'd expect +3. You get +1. Why: count isn't a live wire into React's memory - it's a plain
number that was copied into this render. If count was 0 when the render ran, all three lines say
setCount(0 + 1). Three votes for the same value.
When the next value depends on the current one, pass the setter a function, and React will feed it the latest value:
💡 Key point: each render sees a frozen snapshot of state. setX(newValue) when the new value is
independent; setX(old => new) whenever the new value is computed from the old one. Adopting the
function form as a reflex will also quietly save you from a stale-closure bug in phase 6.
Why you must not mutate
The second trap, and the single most common "React is broken" moment:
const = ;
The screen doesn't update, and here's the exact mechanism: when you call setTodos, React
compares the value you passed with the value it already has - using Object.is, which for arrays and
objects means "is this the same object in memory?" You pushed into the same array and handed the
same array back. Same object, "nothing changed," no re-render. Your data is right and your screen is
wrong - the exact bug class React was supposed to abolish, reintroduced by mutation.
The fix is always the same move: make a new object/array that shares the unchanged parts.
What just happened: spread (...), filter, and map all return new arrays, so the identity
check sees a different object and re-renders. Note the pattern in renameTodo: new array, and a new
object for the one changed item, while untouched items are reused as-is. That's not waste - copying
references is cheap, and it's precisely what lets React's diff skip everything that didn't change.
⚠️ Gotcha: the mutating array methods - push, pop, splice, sort, reverse - all modify
in place. sort is the sneakiest: setTodos(todos.sort(...)) returns the same array, mutated.
Use setTodos([...todos].sort(...)) (or toSorted(...) in modern runtimes).
One more shape: object state
const = ;
Same rule, object edition. form.email = email mutates; { ...form, email } replaces. If you find
yourself spreading three levels deep on every update, that's a signal the state is too nested -
flatten it, or wait for the reducer pattern in a follow-up guide. For this guide's purposes: keep
state flat and small, and the spreads stay one level deep.
Recap
- Local variables reset on every render and can't trigger one - state is React-managed memory plus a re-render trigger.
useStatereturns[value, setter]; calling the setter is what schedules the redraw.- Hooks: top level only, components only - React tracks slots by call order.
- State is a snapshot per render: use
setX(old => new)when new depends on old. - Never mutate. React detects change by object identity, so mutation looks like "no change." New array, new object, every time.
[
{
"q": "You call setItems(items) after items.push(newItem), and the screen doesn't update. Why?",
"choices": [
"push is asynchronous, so the item isn't in the array yet",
"You passed the same array object, so React's identity check sees no change",
"setItems only works with primitive values like numbers and strings",
"The component is missing a key prop"
],
"answer": 1,
"why": [
"push is synchronous - the item is in the array immediately; the data is right and the screen is wrong, which is the signature of a mutation bug.",
null,
"Setters accept any value, including arrays and objects - the constraint is identity, not type.",
"Keys are about list items, and their absence produces a console warning, not a frozen UI."
],
"explain": "React compares old and new state by identity (Object.is). Mutating in place and passing the same reference reads as 'nothing changed'."
},
{
"q": "Inside one click handler you call setCount(count + 1) twice. count was 5. What does the UI show after the re-render?",
"choices": ["7", "6", "5", "It depends on how fast React batches"],
"answer": 1,
"why": [
"Both calls read the same snapshot (5), so both say 'set it to 6' - to get 7 you'd use the function form setCount(c => c + 1) twice.",
null,
"The setter does work - one increment lands; it's the second one that collapses into the first.",
"Batching timing never changes the arithmetic here; the snapshot value does."
],
"explain": "State is a per-render snapshot: both calls computed 5 + 1. The function form setCount(c => c + 1) reads the latest value instead."
},
{
"q": "Why do the rules of hooks forbid calling useState inside an if statement?",
"choices": [
"Conditional state is bad program design",
"React matches state slots to hook calls by their order, and a conditional call changes the order between renders",
"if statements can't contain function calls in JSX",
"It would create a new state slot on every render, leaking memory"
],
"answer": 1,
"why": [
"It's not a style rule - it's a mechanical constraint of how React stores your state.",
null,
"This is regular JavaScript, where function calls are legal anywhere - the constraint comes from React, not the language.",
"Slot creation only happens on the first render either way; the danger is misalignment, not leakage."
],
"explain": "Hook calls are matched to their stored slots positionally. If call #2 sometimes doesn't happen, every hook after it reads the wrong slot."
}
]
← Phase 2: Components and Props · Guide overview · Phase 4: Lists, Keys, and Conditional Rendering →
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. You call setItems(items) after items.push(newItem), and the screen doesn't update. Why?
2. Inside one click handler you call setCount(count + 1) twice. count was 5. What does the UI show after the re-render?
3. Why do the rules of hooks forbid calling useState inside an if statement?