04 · State with useState¶
Props come from outside. State is data a component owns and can change. Changing state is the only normal way to make React re-render and update the screen.
A first counter¶
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
)
}
useState(0) returns a pair: the current value and a setter. Calling setCount
schedules a re-render; on that next render useState returns the new value.
Why not a regular variable?
function BrokenCounter() {
let count = 0
return <button onClick={() => { count++ }}>Clicked {count}</button>
}
Two reasons it fails: changing a local variable doesn't tell React to re-render, and
even if something else re-rendered it, let count = 0 runs again and starts from zero.
State lives outside the function call, managed by React.
State is a snapshot¶
This surprises everyone once:
function Tripler() {
const [n, setN] = useState(0)
function handleClick() {
setN(n + 1)
setN(n + 1)
setN(n + 1)
console.log(n) // still 0
}
return <button onClick={handleClick}>{n}</button>
}
One click shows 1, not 3. Inside this render, n is the constant 0. All three
calls say "set it to 0 + 1". The setter doesn't change the variable you already have;
it asks React for a new render in which n will be different.
Updater functions¶
When the new value depends on the old one, pass a function:
Now one click gives 3. React queues the three functions and runs them in order,
feeding each the result of the previous one.
Rule of thumb: if the next state is computed from the previous state, use the updater form. It is never wrong, and it avoids stale-value bugs in async code.
Objects and arrays: replace, don't mutate¶
React decides whether state changed by comparing old and new with Object.is. If you
mutate an object and pass the same object back, React sees no change.
const [profile, setProfile] = useState({ name: 'Meera', city: 'Kochi' })
// ❌ same object; React may skip the update
profile.city = 'Chennai'
setProfile(profile)
// ✅ new object with the change
setProfile({ ...profile, city: 'Chennai' })
Arrays follow the same idea. Prefer non-mutating operations:
| Goal | Avoid (mutates) | Use (returns new) |
|---|---|---|
| add | push, unshift |
[...arr, item], [item, ...arr] |
| remove | splice |
arr.filter(x => x.id !== id) |
| replace | arr[i] = x |
arr.map(x => x.id === id ? {...x, done: true} : x) |
| sort | sort() |
[...arr].sort() or arr.toSorted() |
Nested objects need copying at every level you change:
Lazy initial state¶
Passing a function means it only runs on the first render. Writing
useState(JSON.parse(...)) would re-read and re-parse storage on every render and
then ignore the result.
Worked example: a shopping list¶
import { useState } from 'react'
let nextId = 3
export default function ShoppingList() {
const [items, setItems] = useState([
{ id: 1, name: 'Rice', bought: false },
{ id: 2, name: 'Curd', bought: true },
])
const [draft, setDraft] = useState('')
function addItem() {
const name = draft.trim()
if (!name) return
setItems(prev => [...prev, { id: nextId++, name, bought: false }])
setDraft('')
}
function toggle(id) {
setItems(prev => prev.map(i => (i.id === id ? { ...i, bought: !i.bought } : i)))
}
function remove(id) {
setItems(prev => prev.filter(i => i.id !== id))
}
const remaining = items.filter(i => !i.bought).length
return (
<div>
<h2>Shopping ({remaining} left)</h2>
<input value={draft} onChange={e => setDraft(e.target.value)} placeholder="Add item" />
<button onClick={addItem}>Add</button>
<ul>
{items.map(item => (
<li key={item.id}>
<label>
<input type="checkbox" checked={item.bought} onChange={() => toggle(item.id)} />
<span style={{ textDecoration: item.bought ? 'line-through' : 'none' }}>{item.name}</span>
</label>
<button onClick={() => remove(item.id)} aria-label={`Remove ${item.name}`}>×</button>
</li>
))}
</ul>
</div>
)
}
Notice that remaining is not state. It's computed from items on every render.
Storing it separately would create two sources of truth that can drift apart.
How It Actually Works¶
React keeps, for each mounted component instance, an internal record (a "fiber"). Each
fiber has an ordered list of hooks. On the first render, each useState call
appends a slot holding the initial value. On later renders React walks the same list:
the first useState call gets slot 1, the second gets slot 2, and so on. There are no
names involved — position is identity. That's why hooks must be called in the same
order every render (never inside if or loops). The Rules of Hooks, covered with
custom hooks in Level 2, are a direct consequence of this design.
Calling a setter doesn't change anything immediately. It pushes an update object
onto that hook's queue and schedules a re-render of the component. When React re-renders,
it processes the queue in order: a plain value replaces the state; an updater function
is called with the current state. The final result is what useState returns this time.
Updates are batched: since React 18, all setter calls made during the same event —
and also inside timeouts, promises and native event handlers — are grouped into a
single re-render. That's why three setN calls cause one render, not three.
Before scheduling, React compares the new value to the current one with Object.is.
If they're identical, React can bail out without re-rendering children. Mutating an
object in place defeats this check, which is the mechanical reason immutability matters.
Finally, each render's event handlers are closures over that render's state values. The
handleClick created during the render where n is 0 will always see 0. This is
the "snapshot" behaviour, and later the root of the "stale closure" bugs discussed with
effects.
Common mistakes¶
- Reading state right after setting it and expecting the new value. Use the value you computed, or read it on the next render.
- Mutating then setting the same reference — the UI doesn't update or updates inconsistently.
- Storing derived data (counts, filtered lists, full names) in state. Compute it during render instead.
- Calling a setter during render (
setX(1)at the top level of the component) causes an infinite render loop: "Too many re-renders". - Writing
onClick={setCount(count + 1)}— that calls the setter during render. It must be a function:onClick={() => setCount(count + 1)}.
Exercise¶
Build a SeatPicker for a 4×5 cinema grid:
- Store the selected seats as an array of seat ids like
"B3"in state. - Clicking a seat toggles it; selected seats get a different background.
- Limit selection to 6 seats — ignore clicks beyond that and show a message.
- Show the total price (₹180 per seat) computed from state, not stored in state.
- Add an "Add two adjacent seats" button that calls a setter twice in the same handler. Make it work correctly using updater functions, and write a one-line comment explaining why the non-updater version would fail.