When useState Lies to You (And Why That’s By Design)

useState doesn’t hold values—it holds *promises*. Its “lies” (frozen reads, delayed writes, closure-scoped snapshots) aren’t bugs—they’re deliberate abstractions that uphold React’s consistency contract.
The Lie Is the Interface
useState doesn’t store values.
It orchestrates time.
That’s not poetic flair—it’s architectural truth. When you write:
const [count, setCount] = useState(0);You’re not declaring a variable. You’re registering a persistent memory slot in React’s fiber tree—a slot that survives renders, participates in reconciliation, and deliberately decouples reading from writing. The count you see isn’t “the current value.” It’s the value this render committed to, frozen at render-time. And setCount doesn’t mutate it—it schedules a future commitment.
This isn’t a leaky abstraction to work around. It’s the foundation of React’s guarantee: render output is deterministic, repeatable, and fully derived from props + state as of that render. If count changed mid-render, every closure, every conditional, every useMemo would become inconsistent. So React lies—gently, consistently, by design.
Let’s unpack why that lie is necessary—and how senior engineers stop fighting it and start composing with it.
The Frozen Snapshot: Why count Never Changes Mid-Render
Consider this:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // Always logs the *old* count
}
return <button onClick={handleClick}>Count: {count}</button>;
}New developers expect count to update synchronously. But React must freeze it. Why?
Because count isn’t a reference—it’s a render-scoped constant, bound at the top of the function body. During render, React evaluates your component once, using the state values as they were when rendering began. If count mutated mid-execution, useEffect, useMemo, and even if branches could observe inconsistent state—breaking referential transparency.
The “lie” (count appears stale) protects a deeper truth: every render is a self-contained, immutable snapshot of UI state. This enables time-travel debugging, server-side rendering, concurrent rendering, and suspense. Without frozen reads, none of those are possible.
The Queued Promise: Why setCount Doesn’t Return a Value
setCount returns undefined. Not because it’s lazy—but because it cannot know the next value yet. React doesn’t apply updates immediately. It queues them in a pending update queue, then applies them after the current render completes and before the next one begins.
This queuing enables batching:
function handleFormSubmit() {
setName('Alice');
setEmail('alice@example.com');
setIsSubmitting(true);
// All three updates → one re-render (React 18+)
}But more importantly, it enables update coalescing. When multiple setCount calls happen in one event handler, React merges them—not arbitrarily, but by design. It preserves ordering and resolves conflicts via the update queue’s FIFO semantics.
That’s why functional updates exist—not as a workaround, but as the only way to express state transitions that depend on prior state in a queued system:
function incrementThrice() {
setCount(c => c + 1); // c = 0 → 1
setCount(c => c + 1); // c = 1 → 2
setCount(c => c + 1); // c = 2 → 3
}Here, c isn’t “the current value”—it’s the value React will resolve when applying this specific update, drawn from the latest known state at application time. That’s not magic. It’s React honoring the contract: “I’ll give you the freshest available state when I actually apply your update.”
The Lazy Initialization Lie: Why Your Initializer Runs Every Time (and How to Stop It)
This looks innocent:
const [data, setData] = useState(expensiveComputation());But expensiveComputation() executes on every render—even though React only uses its return value once. JavaScript evaluates the argument before useState even runs. That’s not React lying—it’s JavaScript doing exactly what it should.
The fix reveals React’s intent:
const [data, setData] = useState(() => expensiveComputation());Now React calls the function—only once, on mount. The parentheses matter: you’re passing a thunk, not a value. This isn’t optimization sugar. It’s React saying: “I control when initialization happens. Don’t assume timing—declare intent.”
Senior engineers reach for lazy initializers not just for performance—but to align their mental model with React’s lifecycle: initialization is part of mounting, not part of evaluation.
The Grouping Lie: Why Seven useState Calls Are Worse Than One Object
Beginners often scatter related state:
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
// ... six moreEach hook creates its own independent update queue, render trigger, and memory slot. Updating one field forces a re-render even if no other field changed. Worse: it fragments related logic across closures, making derived state error-prone.
The alternative isn’t always “put everything in one object.” It’s grouping by coherence—state that changes together, shares lifecycles, or belongs to the same domain concept:
const [user, setUser] = useState({
firstName: '',
lastName: '',
email: ''
});
const [address, setAddress] = useState({
street: '',
city: '',
zipCode: ''
});Now setUser batches all personal fields; setAddress batches all location fields. Updates are atomic within their domain. This isn’t about reducing hook count—it’s about modeling state along natural boundaries, so React’s guarantees (consistency, batching, isolation) map cleanly to your domain logic.
The Derived Truth: Why Storing itemCount Is a Violation of Contract
This pattern seems harmless:
const [items, setItems] = useState([]);
const [itemCount, setItemCount] = useState(0);
function addItem(item) {
setItems([...items, item]);
setItemCount(itemCount + 1); // 🚩 Two sources of truth
}But it breaks React’s core invariant: state must have a single, authoritative source. Now itemCount is a cached approximation—and caching without invalidation guarantees inconsistency.
The fix isn’t clever memoization. It’s removing the lie entirely:
const [items, setItems] = useState([]);
const itemCount = items.length; // Always true. Always free.Derived state computed during render is never stale. It’s not cached—it’s recomputed on demand, from the single source of truth. This eliminates entire classes of bugs: race conditions, missed updates, desynced lifecycles. It treats React’s render function not as a side-effecting procedure, but as a pure projection—exactly as the design intends.
Embracing the Lie
useState doesn’t lie to confuse you. It lies to protect consistency, enable concurrency, and make rendering predictable. Its “deceptions”—frozen reads, queued writes, render-scoped closures—are not implementation details to bypass. They’re abstraction boundaries to lean into.
Senior developers don’t ask “How do I get the latest value right now?”
They ask: “What does my UI mean at this render? What state does it commit to? What transition do I want to schedule for the next?”
That shift—from imperative mutation to declarative commitment—is where useState stops feeling like a trap… and starts feeling like a language.


