Lesson 50·M6 React Fundamentals
State & useState
OpenLearning Objectives
By the end of this lesson you should be able to:
- Create and update state with
useState, and say why it must be a hook - Explain why updates are asynchronous and what "snapshot" means
- Predict what batching does to several updates in one handler
- Use functional updates and an initialiser function correctly
- Diagnose and fix the stale-closure trap (the Lesson 5 connection)
One-line definition
State is data a component owns; useState declares it, and setState schedules a
re-render instead of changing a variable — updates are asynchronous, queued, and batched.
Mental model
Imagine a photo of the UI. React renders the picture, and count is a number written on it.
Calling setCount(1) doesn't change the photo — it schedules a new photo, and only when
React takes it does count read as 1.
That's why the value doesn't change immediately: you're reading the current photo while the
next one is still being prepared. useState hands you the snapshot for this render, and a
setter that asks for the next one.
Visual flow
render #1: count = 0 snapshot for the UI being drawn
│
▼
onClick → setCount(0 + 1) schedules an update (queued, not applied)
onClick → setCount(1 + 1) queued — batch
│
▼
render #2: count = 2 one re-render, snapshot updated
│
▼
setCount(count + 1) reads THIS render's snapshot (2) → 3
Three setCount calls in one handler → one re-render with the final value. Each render
reads its own snapshot and no other.
How it works
A useState call returns a pair — the current value (a snapshot for this render) and a
setter that schedules an update:
function Counter() {
const [count, setCount] = useState(0); // 0: initial value
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
The hook has two jobs — read and schedule. It is not a variable: you can't reassign
count, and the setter doesn't apply instantly.
Updating multiple state variables in one handler is a batch — one re-render, not two:
function Form() {
const [name, setName] = useState('');
const [age, setAge] = useState(0);
function reset() {
setName(''); // queued …
setAge(0); // … and flushed together
}
return null;
}
name and age update in the same render. The parent component is part of the same batch —
React re-renders the whole tree that needs it in one pass.
The functional updater reads the latest value instead of a snapshot — the fix for "stuck at 1" and the safe form for multiple updates:
setCount(prev => prev + 1); // prev is the latest pending value
An initialiser function delays the initial computation until it's actually needed —
for reading localStorage, scanning a big array, or parsing JSON:
const [todos, setTodos] = useState(() => loadTodosFromStorage());
loadTodosFromStorage runs once, on the first render only.
Pitfall
Don't confuse the initialiser with lazy updates: useState(load()) calls load on
every render and discards the result — the argument is still evaluated. The function
form useState(() => load()) is what defers it.
Real project usage
| Pattern | Code |
|---|---|
| Basic toggle | const [open, setOpen] = useState(false) |
| Form fields | const [query, setQuery] = useState('') — controlled input (Lesson 54) |
| Counters/quantities | setQuantity(q => q + 1) — functional updater |
| Expensive init | useState(() => parse(bigData)) — initialiser function |
| Multiple related | several useState calls, or useReducer when transitions get complex (Lesson 64) |
| Shared state | lift to the common parent (Lesson 55) or context (Lesson 63) |
The stale-closure trap in real life — the counter that gets stuck:
useEffect(() => {
const id = setInterval(() => setCount(count + 1), 1000); // ❌ stuck at 1
return () => clearInterval(id);
}, []);
The effect ran once, so its closure captured count as 0 forever (Lesson 5). Every tick
computes 0 + 1. Two honest fixes:
setCount(prev => prev + 1); // ✅ stop reading the captured snapshot
useEffect(() => { … }, [count]); // ✅ or re-create the closure when it changesInterview explanation
useStategives a component a value for this render and a setter that schedules an update. Updates aren't applied instantly — React queues them, batches everything in a handler or event into one re-render, and only then produces a new snapshot. So consecutivesetCount(count + 1)calls each read the same old value; the functional formsetCount(prev => prev + 1)reads the latest. The common bugs — a counter stuck at one, an interval seeing stale state — are closures capturing an old snapshot, the same mechanism as thevarloop (Lesson 5).
Senior-level insights
- State is per-render, and so are the closures around it. Every render creates fresh functions capturing that render's state — one snapshot, one set of closures.
- Treat the setter as a message, not a command. You're telling React "the next render should have this"; you are not editing the current one.
- Separate the three sources of truth. Props are external inputs (Lesson 49), state is
internal and owned, and derived values are computed in render (Lesson 55).
useMemois for expensive derivation, not for storing things. - One state, one owner. The component that renders the value owns it; siblings share through a common parent (Lesson 55) — never by duplicating it.
- Read React 18 batching precisely. React 18 batches everything — timeouts, promises, native handlers included. In React 17 and earlier, batching happened only in React event handlers; async callbacks re-rendered each time.
Common mistakes
- Mutating state instead of replacing it —
todos.push(x)thensetTodos(todos). Same reference, so React sees no change (Lesson 6's reference equality) and nothing re-renders. Copy first:setTodos([...todos, x]). - Reading state right after setting it —
setCount(count + 1); console.log(count). You read the old snapshot; updates are queued, not applied. - Stale closures — a
setTimeoutorsetIntervalcapturing an oldcount(Lesson 5). - Unnecessary state —
useStatefor something already derivable from props or other state. "Most out-of-sync bugs are state that should have been derived" (Lesson 55). - Wrong initialiser —
useState(load())runs the call every render. - Several
setStatecalls updating different slices of one object — three separate objects, three churn points. Use one state object,useReducer, or three independent states (Lesson 64). - Resetting via re-mount — clearing a form by remounting it with a different
keyinstead of resetting state; it works, but it's a hack.
Best practices
✅ Name the pair after meaning: const [isOpen, setIsOpen] = useState(false)
✅ Use the functional updater when the next value depends on the previous one
✅ Use the initialiser function for expensive or derived initial values
✅ Keep related values in one state object or a reducer (Lesson 64)
✅ Treat state as immutable — copy, don't mutate (Lesson 20's spread patterns)
❌ Don't read state right after setting it — you're reading the old snapshot
❌ Don't duplicate state that can be derived (Lesson 55)
❌ Don't write state during render — that's a render loop, not a pattern
Interview questions
Q1. Why are state updates asynchronous?
Because the setter schedules a re-render rather than mutating a variable. The component keeps rendering from the current snapshot until React processes the update and produces a new one. So after
setCount(x)the localcountis still the old value — reading it is reading the render in progress.
Q2. What is batching, and how does React 18 change it?
Several updates in the same event handler are queued and flushed as one re-render, so
setName+setAge→ one render. React 18 batches everything — promises, timeouts, native events — where earlier React only batched inside React event handlers and re-rendered per update outside them.
Q3. Why do two setCount(count + 1) calls add only one?
Both read the same snapshot —
countfrom this render — so both compute the same value. The setter schedules, it doesn't apply. Use the functional formsetCount(prev => prev + 1)to read the latest queued value instead.
Q4. What's the difference between passing a value and passing a function to the setter?
A value schedules that exact value. A function receives the latest pending value and returns the next one — correct when the update depends on previous state, and it never reads a stale snapshot. Prefer the function form when the next value derives from the current one.
Q5. Why does useState(expensive()) not defer the work?
The argument is evaluated before
useStateis even called — every render. The initialiser function formuseState(() => expensive())defers it and runs it once, on the first render. Same distinction as passing a value vs a function to any API.
Q6. When would you use useReducer instead of useState?
When transitions are complex or related — multiple fields, state that changes based on actions.
useReducercentralises the logic in one pure function (Lesson 14), which is easier to test and reason about. For a toggle or a counter,useStateis the tool (Lesson 64).
Senior follow-up: Walk through what happens end-to-end after a setState call.
The setter enqueues an update on the component's fiber and schedules work. After the event handler finishes, React processes the queue — including the functional updates in order, each receiving the previous result — then re-renders affected components, reconciles the new element tree against the old (Lesson 51), and commits the minimal DOM changes. All the updates from that event were batched, so there's one render pass and one commit.
Follow-up questions
Why is state stored in order rather than in an object keyed by name?
Because hooks have no names — the hook array is indexed by call order. That's why hooks can't live inside conditions or loops (Lesson 66). The order of your
useStatecalls is the identity of your state.
Why does a re-render reset my let variables but not my state?
A plain variable is recreated from scratch on every render; state lives on the fiber, which persists across renders (Lesson 66). That persistence — plus the fact that only state changes trigger re-renders — is the whole difference.
Can you use useState outside a component?
The hook itself needs a component context, but the pattern is plain closures — the
useStatedeep-dive in Lesson 5 is exactly that. If you want shared mutable data outside a component, that's a store, not state (Lesson 77).
Comparison table
const x = v | useState | useRef (Lesson 60) | |
|---|---|---|---|
| Survives re-renders | ❌ recreated | ✅ stored on fiber | ✅ stored on fiber |
| Changing it re-renders | — | ✅ | ❌ |
| Readable in this render | ✅ | ✅ (snapshot) | ✅ (live) |
| Writable in this render | ✅ | ❌ via setter | ✅ .current |
| Use for | pure computation | UI-affecting data | timers, DOM refs, latest values |
Code example
import { useState } from 'react';
function Counter() {
const [count, setCount] = useState(0);
function addThree() {
// All three read the same snapshot — the result is +3, not +1.
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
function addOneCorrectly() {
setCount(prev => prev + 1); // functional form — reads the latest
}
return (
<div>
<p>Count: {count}</p>
<button onClick={addThree}>addThree</button>
<button onClick={addOneCorrectly}>+1</button>
</div>
);
}
// The pattern behind useState — state is just a closure over a value (Lesson 5):
function createState(initial) {
let value = initial;
const set = (next) => { value = next; };
return [value, set];
}
// Simulating the batch with plain JS — updates are queued, then applied together:
let queue = [];
const dispatch = (updater) => { queue.push(updater); };
const flush = () => {
let current = 0; // the "state" being updated
queue.forEach(update => { current = update(current); });
queue = [];
return current; // the one new snapshot
};
dispatch(prev => prev + 1); // three queued functional updates
dispatch(prev => prev + 1);
dispatch(prev => prev + 1);
console.log(flush()); // one flush, final value
Output:
3
React's real implementation stores the value on the fiber and runs the queue before render — but "queued updates, applied in order, one new snapshot" is exactly the model.
Performance notes
- One render per batch, not per setter — batching is why handlers can call setters freely.
- The initialiser function is a real optimisation when init is expensive (parsing, storage, big scans) — it runs once instead of every render.
- Functional updates are O(1) to queue; they also fix stale-closure bugs without extra dependencies.
- Frequent updates (animations, streaming) should stay out of state where possible —
useRef(Lesson 60) holds values that shouldn't re-render. And re-renders themselves are the thing to measure (Lesson 71), not the number of setters.
Debugging scenarios
The counter is stuck at 1. A closure captured count from the first render — the
setInterval from Lesson 5. Fix with the functional updater or add the dependency.
The UI doesn't change after you called the setter. You mutated instead of replacing —
todos.push(x) keeps the same reference, so React skips the render. Copy: setTodos([...todos, x]).
Two updates in a row only apply the second. Both read the same snapshot. Switch to
functional updates: setQty(prev => prev + 1).
The setter logs the old value. Reading state right after setting it is reading the
current snapshot — updates are queued. Log inside the render or in a useEffect.
State resets when you navigate. The component unmounted and remounted, so the fiber state was discarded. Move the state up (Lesson 55) or to context (Lesson 63) if it must survive.
Quick revision notes
useState(initial)→[value, setter]; value is a snapshot for this render- Setters schedule, they don't apply — updates are asynchronous
- Updates are batched: one handler → one re-render (React 18 batches everything)
- The functional updater reads the latest:
setCount(prev => prev + 1) - The initialiser function defers init:
useState(() => expensive()) - Two snapshot reads of the same state add one, not two
- Stale closures are Lesson 5 wearing a hoodie
- Don't mutate state — replace it (reference equality, Lesson 6)
- Don't read state right after setting it
- Don't store what can be derived (Lesson 55)
useRefwhen changes must not re-render (Lesson 60);useReducerfor complex transitions (Lesson 64)
Cheat sheet
// Declare
const [count, setCount] = useState(0);
const [user, setUser] = useState(() => loadUser()); // lazy init
// Update — value form (schedules this exact value)
setCount(5);
// Update — functional form (reads the latest; use when deriving)
setCount(prev => prev + 1);
// Never mutate — copy and replace (Lesson 20)
setTodos([...todos, { id, text }]);
setUser({ ...user, name: 'Mansha' });
// Common bug: these three add 1, not 3 (same snapshot)
// setCount(count + 1); setCount(count + 1); setCount(count + 1);
// Derived value — compute in render, don't store (Lesson 55)
const fullName = `${user.first} ${user.last}`;Key takeaways
Recap
- State is data a component owns;
useStatereturns a snapshot plus a scheduler - Updates are asynchronous and batched — one handler, one re-render
- The functional updater reads the latest value; the value form reads the snapshot
- The initialiser function defers expensive first-render work
- Never mutate state — replace it, or reference equality hides the change (Lesson 6)
- Stale closures are the
varloop wearing a hook (Lesson 5) - React 18 batches across events, promises and timeouts — not just handlers
- State is stored on the fiber by call order — that's the Rules of Hooks (Lesson 66)
- Don't store what can be derived; lift what must be shared (Lesson 55)
Check your understanding
Answer each one out loud. Then rate yourself honestly.
0/8Why can't you read the new value immediately after calling setCount?
Three setCount(count + 1) calls in one handler — what's the final value, and why?
What does the functional updater receive, and when must you use it?
Why does useState(loadTodos()) run the call every render?
Explain the setInterval "stuck at 1" bug in terms of Lesson 5, and give two fixes.
How is React 18's batching different from React 17's?
Why does mutating an array and then setting it not re-render?
Where does React actually store state, and what does that imply about hook order?
What's Next
Lesson 51 — Rendering & Reconciliation. What actually happens when state changes: how
React decides what to re-render, why reference identity matters, and what key does to the
diff.