Module 3: State and Interactivity
useState and State as a Snapshot
Where state actually lives, why setting it doesn't change the variable you're holding, updater functions, batching, and how to update objects and arrays without mutating.
useState looks easy. You call it, you get a value and a setter, and the first demo works. The trouble starts about a week later when a click handler logs the wrong number and you can't see why. This lesson is about getting the mental model right, so that never happens.
The basics#
import { useState } from "react";
function LikeButton() {
const [likes, setLikes] = useState(0);
return <button onClick={() => setLikes(likes + 1)}>Likes: {likes}</button>;
}
useState(0) returns a pair: the current value, and a function to request a new one. The 0 is the initial value. React only uses it on the very first render and ignores it after that.
Where the value actually lives#
Your component function runs and finishes. Local variables vanish. So where does likes survive between renders?
React stores it, outside your function, attached to the spot in the tree where your component sits. Internally React keeps a list of "hook slots" for each component, and every time your function runs, each useState call picks up the next slot in order.
Component instance (kept by React)
┌──────────────────────────────┐
│ slot 0: likes = 3 │ ◄── first useState call
│ slot 1: name = "Asha" │ ◄── second useState call
│ slot 2: isOpen = false │ ◄── third useState call
└──────────────────────────────┘
This is the real reason for the rules of hooks. React doesn't know your variable names. It matches calls to slots purely by the order they happen in. If you put a hook inside an if, the order can change between renders, slot 1 suddenly holds slot 2's data, and things go badly wrong. Always call hooks at the top level of the component, never in conditions or loops.
State is a snapshot#
Here's the part that confuses nearly everyone. Predict what this prints and what the label shows after one click:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
console.log(count);
}
return <button onClick={handleClick}>{count}</button>;
}
It logs 0, and the button shows 1. Not 3.
Each render gets its own frozen copy of the values. During this render, count is 0. It's a constant for the whole life of this render, and every function created in this render, including handleClick, closes over that 0. Calling setCount doesn't reach in and change the variable. It tells React "schedule a new render with this value."
So the three calls all say setCount(0 + 1). Three requests for the number 1.
render #1 (count = 0)
click ──► handleClick sees count = 0
setCount(1), setCount(1), setCount(1)
log: 0
│
▼
render #2 (count = 1) ◄── new snapshot, new function, new closure
The snapshot idea also explains why a handler attached to an earlier render still sees the old value, even if state has since moved on.
Updater functions#
If the next value depends on the previous one, hand React a function instead of a value:
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
// result: 3
React queues these. When it's time to compute the new state, it feeds each updater the result of the one before it. The parameter is conventionally the first letters of the state name, like c for count. Use this whenever the new value is derived from the old, especially inside timers and async code, where the closed-over value could be very old.
Batching#
React doesn't re-render after every setState call. It waits until your event handler finishes, applies all the queued updates, and renders once.
function handleSubmit() {
setName("");
setEmail("");
setSubmitted(true);
// one render, not three
}
That's been true for all updates, including inside promises and timeouts, since React 18. You don't need to do anything to get this. It does mean you can't call a setter and expect the next line to see the new value.
Never mutate state#
State must be treated as read-only. You replace it with a new value, you don't edit the old one.
const [user, setUser] = useState({ name: "Asha", city: "Pune" });
// Wrong: same object, React sees no change
user.city = "Delhi";
setUser(user);
// Right: new object
setUser({ ...user, city: "Delhi" });
React decides whether to re-render by comparing the old and new value with Object.is. Same reference means "nothing changed." Mutating and re-setting the same object gives an identical reference, so React bails out and your screen goes stale. And it's worse than that, because other code holding the old reference sees it silently change underneath it.
Here are the patterns you'll use over and over.
// Objects: spread and override
setUser({ ...user, city: "Delhi" });
// Nested objects: spread at every level you touch
setUser({ ...user, address: { ...user.address, zip: "411001" } });
// Arrays: add
setItems([...items, newItem]);
// Arrays: remove
setItems(items.filter((i) => i.id !== id));
// Arrays: change one
setItems(items.map((i) => (i.id === id ? { ...i, done: !i.done } : i)));
Deeply nested state gets annoying fast. That's usually a hint to flatten the shape. If you truly need nested updates, libraries like Immer let you write mutating-looking code that produces copies. Redux Toolkit uses it internally, and we'll see it in module 6.
Lazy initial state#
useState(expensive()) calls expensive() on every render, even though only the first result is kept. Pass a function and React only calls it once.
const [data, setData] = useState(() => JSON.parse(localStorage.getItem("data") ?? "[]"));
One piece of state or many#
Group values that always change together into one object. Keep unrelated values separate.
// These move together
const [position, setPosition] = useState({ x: 0, y: 0 });
// These don't
const [name, setName] = useState("");
const [isOpen, setIsOpen] = useState(false);
If you find yourself writing a dozen useState calls with intertwined updates, useReducer (module 4) is the cleaner option.
Resetting state with a key#
State belongs to a position in the tree. If you want a component to start fresh when some identity changes, like switching to a different user, give it a key:
<ProfileEditor key={user.id} user={user} />
When key changes, React discards the old instance (and all its state) and mounts a new one. It's often a much simpler fix than writing an effect that watches user.id and manually resets fields.
Where people get burned#
- Calling the setter three times with
count + 1and expecting three increments. - Logging state right after setting it, then concluding the setter is broken.
- Mutating arrays with
push,splice,sortorreverseon state. - Putting derived values in state and then having to keep them in sync.
- Calling
useStateinside anifor a loop. - Calling the setter during render (
setX(...)in the body unconditionally). That causes an infinite loop and React throws "Too many re-renders".
Practice#
- Build a counter with
+1,+3and "reset" buttons. For+3, do it two ways, once with three updater calls and once with a singlesetCount(count + 3). Explain why both work. - Make a to-do list where you can add, toggle and delete items, with no mutation anywhere.
- Create a form object in state with
name,emailand a nestedaddress.city. Update each field immutably. - Add a
setTimeoutin a click handler that logscountafter three seconds. Click the button, then click "+1" a few more times. Predict and verify what gets logged.