04 — State & useState
useState Anatomy
usestate_anatomy.js
import { useState } from 'react'
// useState returns [currentState, setStateFunction]
// - currentState: the value AT THIS RENDER (snapshot, not a live reference)
// - setStateFunction: schedules a re-render with the new value
const [count, setCount] = useState(0)
// WHAT THE SETTER DOES:
// 1. Queues the new state value
// 2. Schedules a re-render (does NOT happen synchronously)
// 3. On next render, useState returns the NEW value
// CRITICAL MENTAL MODEL:
// `count` is a SNAPSHOT of state at the time this render happened.
// It does NOT update in-place when you call setCount.
// Between calling setCount and the next render, `count` is STILL the old value.
function Counter() {
const [count, setCount] = useState(0)
function handleClick() {
setCount(count + 1) // schedules re-render with count + 1
console.log(count) // STILL logs the OLD value — count is a snapshot
}
return <button onClick={handleClick}>{count}</button>
}
Functional Updates vs Direct Values
functional_updates.js
// ANTI-PATTERN: using the current state variable directly in the update
function BadCounter() {
const [count, setCount] = useState(0)
function incrementThree() {
setCount(count + 1) // uses count from THIS render
setCount(count + 1) // uses SAME count from THIS render → all three set to count + 1
setCount(count + 1) // result: count increases by 1, not 3
}
// All three calls use the same stale `count` snapshot. React batches them
// and applies them as three "set to count + 1" operations — net result: +1.
}
// PRODUCTION: use the functional updater form when the next state depends on the previous
function GoodCounter() {
const [count, setCount] = useState(0)
function incrementThree() {
setCount(prev => prev + 1) // prev is the LATEST queued state, not the render snapshot
setCount(prev => prev + 1) // prev = result of previous update
setCount(prev => prev + 1) // result: count increases by 3
}
// The functional form receives the LATEST state (after all queued updates),
// not the snapshot from the current render. Each updater builds on the previous.
}
React 18 Automatic Batching
automatic_batching.js
// React 18 batches ALL state updates — in event handlers, timeouts, promises,
// and native event handlers. React 17 only batched inside React event handlers.
function Search({ query }) {
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
async function handleSearch() {
setLoading(true) // ─┐
setError(null) // ─┤ — batched: ONE re-render, not three
setResults([]) // ─┘
try {
const data = await fetchResults(query)
setResults(data) // ─┐ batched: ONE re-render
setLoading(false) // ─┘
} catch (err) {
setError(err.message) // ─┐ batched: ONE re-render
setLoading(false) // ─┘
}
}
// In React 17, the await boundary would UN-batch — setLoading(true) and
// setError(null) would cause separate renders. React 18 batches everything.
}
The Stale Closure Problem
stale_closure.js
// ANTI-PATTERN: stale closure in event handlers or timers
function Poller() {
const [count, setCount] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
// This closure captures `count` from the FIRST render.
// Every interval tick uses count = 0 → setCount(0 + 1) → setCount(1) every time.
setCount(count + 1)
}, 1000)
return () => clearInterval(interval)
}, []) // empty deps → effect runs once → closure captures count = 0 forever
return <div>{count}</div>
}
// FIX 1: functional updater (doesn't need count in the closure)
function Poller() {
const [count, setCount] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setCount(prev => prev + 1) // prev is always the latest state
}, 1000)
return () => clearInterval(interval)
}, [])
return <div>{count}</div>
}
// FIX 2: include count in the dependency array (re-creates interval each change)
function Poller() {
const [count, setCount] = useState(0)
useEffect(() => {
const interval = setInterval(() => setCount(count + 1), 1000)
return () => clearInterval(interval)
}, [count]) // re-runs effect when count changes → fresh closure
return <div>{count}</div>
}
// FIX 2 clears and re-creates the interval on every count change — slightly
// less efficient but needed if the closure uses count for other logic too.
Lazy Initialization
lazy_init.js
// ANTI-PATTERN: expensive computation on every render (even though useState
// ignores the result after the first render, it STILL computes the initial value)
function BadComponent() {
// computeExpensiveValue() runs on EVERY render — useState only uses the
// first result, but the function is still called every time.
const [data, setData] = useState(computeExpensiveValue())
}
// PRODUCTION: lazy initializer — the function runs ONLY on the first render
function GoodComponent() {
// useState accepts a function; calls it once, uses the return value.
const [data, setData] = useState(() => {
const cached = localStorage.getItem('data')
return cached ? JSON.parse(cached) : computeExpensiveValue()
})
// The arrow function runs ONLY on mount. On subsequent renders, useState
// returns the current state without calling the initializer again.
}
Multiple State Fields: Separate vs Object
state_field_patterns.js
// PATTERN 1: separate useState calls — independent updates, simpler mental model
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [age, setAge] = useState(0)
// Each can update independently. No accidental overwrites of other fields.
// PATTERN 2: single object state — related fields that update together
const [form, setForm] = useState({ name: '', email: '', age: 0 })
// MUST use spread to merge — setForm({ name: 'X' }) ERASES email and age!
function updateField(field, value) {
setForm(prev => ({ ...prev, [field]: value })) // ← spread preserves other fields
}
// ANTI-PATTERN: replacing the object instead of merging
setForm({ name: 'Alice' }) // email and age are now undefined! Object replaced.
// RULE OF THUMB:
// - Fields that update independently → separate useStates
// - Fields that update together / represent one entity → single object (or useReducer, Ch 10)
// - For complex state logic with interdependencies → useReducer (Ch 10)
Anti-Pattern: Deriving State from Props
anti_pattern_derived_state.js
// ANTI-PATTERN: copying props into state and syncing manually
function BadEmailInput({ initialEmail }) {
const [email, setEmail] = useState(initialEmail)
// If initialEmail changes after mount, this state is STALE — useState only
// reads the initial value once. Adding a useEffect to sync is an anti-pattern
// that causes an extra render and can cause "flashing" of the old value.
useEffect(() => {
setEmail(initialEmail) // ← BAD: extra render, potential flicker
}, [initialEmail])
return <input value={email} onChange={e => setEmail(e.target.value)} />
}
// PRODUCTION: use the key prop to reset the component when the prop changes
function GoodEmailInput({ initialEmail }) {
const [email, setEmail] = useState(initialEmail)
return <input value={email} onChange={e => setEmail(e.target.value)} />
}
// Parent usage: <GoodEmailInput key={userId} initialEmail={user.email} />
// Changing key unmounts and remounts the component → state resets cleanly.
// No syncing effects, no extra renders, no flicker.
💡 Tips & Tricks
tips.js
// [Idiom] Use functional updates (prev => prev + 1) whenever the next state
// depends on the previous. This avoids stale closures and batch-update bugs.
// [Performance] Use lazy initialization useState(() => expensive()) for any
// initial value that involves computation, parsing, or I/O. Runs once, not
// every render.
// [Debug] If state updates seem to "not apply" or only apply partially, check
// for direct-value updates that should be functional updates. Multiple
// setCount(count + 1) calls in the same handler all use the same snapshot.
// [Idiom] For related state that updates together, use a single object with
// spread: setForm(prev => ({ ...prev, [field]: value })). For complex logic,
// use useReducer (Ch 10) — the reducer centralizes all state transitions.
// [Idiom] To reset a component's state, change its key prop — React unmounts
// and remounts it. Cleaner than syncing state with useEffect.
⚠️ Edge Cases & Gotchas
edge_cases.js
// [Gotcha] State is a SNAPSHOT, not a live reference. After setCount(5),
// `count` is still the old value until the next render. Accessing count
// immediately after setCount logs the stale value.
// [Gotcha] Object/array state requires a NEW reference to trigger re-render.
// setItems(items.push(newItem)) → BUG: push mutates the array in place,
// React sees the same reference → no re-render.
// FIX: setItems([...items, newItem]) — spread creates a new array reference.
// [Gotcha] useState with the SAME value (Object.is comparison) does NOT
// trigger a re-render. setCount(5) when count is already 5 → no-op, no render.
// But setCount({ ...count }) (new object with same content) → DOES re-render,
// because Object.is({}, {}) is false. Don't create new objects unless you mean it.
// [Gotcha] In React 17, state updates outside React event handlers (setTimeout,
// fetch callbacks) were NOT batched → each setX caused a separate render.
// React 18 batches ALL updates everywhere. If you relied on un-batched renders
// for sequencing, behavior changes on upgrade.
// [Gotcha] Calling a state setter during render (not in an event handler or
// effect) throws "Cannot update a component while rendering a different component"
// — but calling YOUR OWN setter during render IS allowed (it's the "getDerivedState
// from props" escape hatch). Use sparingly — it causes an immediate re-render.
🧠 Spot the Bug
A counter should increment by 3 when clicked, but it only goes up by 1:
spot_the_bug.js
function TripleCounter() {
const [count, setCount] = useState(0)
function handleClick() {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
}
return <button onClick={handleClick}>{count}</button>
}
Answer
All three setCount(count + 1) calls reference count from the same render snapshot. React batches the three updates, but each one computes count + 1 using the original count value (0), so all three set state to 1 — net result: +1, not +3.
Fix: use the functional updater form, which receives the latest queued state:
setCount(prev => prev + 1) // prev = 0 → sets to 1
setCount(prev => prev + 1) // prev = 1 → sets to 2
setCount(prev => prev + 1) // prev = 2 → sets to 3
Each updater builds on the result of the previous one, giving the correct +3 result.
Key Takeaways
key_takeaways.js
// 1. State is a SNAPSHOT, not a live reference. `count` doesn't update until
// the next render. Accessing it immediately after setCount gives the old value.
// 2. Use functional updates (prev => prev + 1) when the next state depends on
// the previous. Direct-value updates (count + 1) use the stale snapshot.
// 3. React 18 batches ALL state updates (event handlers, timeouts, promises).
// Multiple setState calls in one handler → ONE re-render.
// 4. Lazy initialization: useState(() => expensive()) runs only on mount.
// Without the arrow function, the initial value computes on EVERY render.
// 5. Object/array state needs a NEW reference to trigger re-render:
// setItems([...items, newItem]) — NOT items.push(newItem) (in-place mutation).
// 6. Don't derive state from props with useEffect + setState. Use the key prop
// to reset component state when a prop changes — cleaner, no extra render.