useState Deep Dive
useState Deep Dive
LinkedIn Hook
You think you know
useState. You've used it a thousand times.But can you explain lazy initialization? Do you know why passing an object to
useStateis a trap most developers walk into? Can you describe the stale state problem without hesitating?In interviews,
useStateisn't a warmup — it's a minefield. Interviewers use it to separate developers who use React from developers who understand React.In this lesson, I go deep: lazy initialization for expensive computations, functional updates that actually work under pressure, immutable patterns for objects and arrays, the tradeoffs of multiple state variables vs a single object, and the stale state problem that has tripped up senior engineers in live coding rounds.
If you've ever mutated state and wondered why your component didn't re-render — this one will save you.
Read the full lesson → [link]
#React #JavaScript #InterviewPrep #Frontend #CodingInterview #ReactHooks #useState #100DaysOfCode
What You'll Learn
- Lazy initialization — how to avoid expensive computations on every render
- Functional updates — the safe pattern for state that depends on previous values
- Immutable update patterns for objects and arrays (the interview essential)
- Multiple state variables vs a single state object — when to use which
- The stale state problem — what causes it and how to fix it
The Concept — useState Beyond the Basics
Analogy: The Recipe Notebook
Imagine you keep a recipe notebook in your kitchen. Every time you cook, you open it to the right page.
Lazy initialization is like writing the table of contents only the first time you open the notebook — not rewriting it every single time you pick it up. Expensive setup, done once.
Functional updates are like telling your assistant "add one more egg to whatever the current recipe says" instead of "set it to 3 eggs" — because someone else might have already changed the recipe while you weren't looking.
Immutable updates are the golden rule of the notebook: you never erase a page. Instead, you copy the page, make your changes on the copy, and replace the original. That way, if anything goes wrong, you still have the old version — and React can see that the page actually changed.
Stale state is what happens when you photocopy a page, walk away for an hour, and make decisions based on that old photocopy — not realizing the original page has been updated three times since then.
Lazy Initialization
When you pass a value to useState, React uses that value only on the first render. But if that value comes from an expensive computation, the computation still runs on every render — React just ignores the result after the first time.
Code Example 1: Lazy Initialization
import { useState } from "react";
// BAD: parseData runs on EVERY render, even though only the first result is used
function ExpensiveList({ rawData }) {
const [items, setItems] = useState(parseData(rawData));
// parseData(rawData) executes every render — wasted work
return <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}
// GOOD: Pass a function — React only calls it on the FIRST render
function ExpensiveList({ rawData }) {
const [items, setItems] = useState(() => parseData(rawData));
// parseData(rawData) runs once. The arrow function is cheap to create.
return <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>;
}
// Another common use case: reading from localStorage
function UserSettings() {
const [settings, setSettings] = useState(() => {
// This runs only once — reading from localStorage on every render would be slow
const saved = localStorage.getItem("settings");
return saved ? JSON.parse(saved) : { theme: "dark", fontSize: 16 };
});
return <p>Theme: {settings.theme}</p>;
}
// Output on first render: Theme: dark (loaded from localStorage or default)
// On subsequent renders: Theme: dark (function not called again)
The rule: Pass a function to useState when the initial value requires computation (parsing, localStorage reads, heavy calculations). Pass a plain value when it's cheap (numbers, strings, booleans).
State with Objects and Arrays — Immutable Patterns
React uses reference comparison (Object.is) to detect state changes. If you mutate an object in place, the reference stays the same, and React skips the re-render. You must create a new reference every time.
Code Example 2: Immutable Object Updates
function UserProfile() {
const [user, setUser] = useState({
name: "Alice",
age: 28,
address: { city: "Dhaka", zip: "1205" }
});
// BAD: Mutating the existing object — React won't re-render
function handleBadUpdate() {
user.name = "Bob"; // Mutating the same object reference
setUser(user); // Same reference — React sees no change, skips re-render
}
// GOOD: Creating a new object with spread — React detects the change
function handleNameChange() {
setUser(prev => ({
...prev, // Copy all existing properties
name: "Bob" // Override the one that changed
}));
}
// GOOD: Updating nested objects — spread at every level
function handleCityChange() {
setUser(prev => ({
...prev,
address: {
...prev.address, // Copy nested object
city: "Chittagong" // Override nested property
}
}));
}
return (
<div>
<p>{user.name} — {user.address.city}</p>
<button onClick={handleNameChange}>Change Name</button>
<button onClick={handleCityChange}>Change City</button>
</div>
);
}
// After clicking "Change Name":
// Output: Bob — Dhaka
//
// After clicking "Change City":
// Output: Bob — Chittagong
Code Example 3: Immutable Array Updates
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: "Learn hooks", done: false },
{ id: 2, text: "Practice interviews", done: false }
]);
// Add an item — spread existing array and append
function addTodo(text) {
setTodos(prev => [
...prev,
{ id: Date.now(), text, done: false }
]);
}
// Remove an item — filter creates a new array
function removeTodo(id) {
setTodos(prev => prev.filter(todo => todo.id !== id));
}
// Update an item — map creates a new array with one item replaced
function toggleTodo(id) {
setTodos(prev =>
prev.map(todo =>
todo.id === id
? { ...todo, done: !todo.done } // New object for the changed item
: todo // Keep unchanged items as-is
)
);
}
return (
<ul>
{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.done ? "line-through" : "none" }}>
{todo.text}
<button onClick={() => toggleTodo(todo.id)}>Toggle</button>
<button onClick={() => removeTodo(todo.id)}>Remove</button>
</li>
))}
<button onClick={() => addTodo("New task")}>Add</button>
</ul>
);
}
// Initial render:
// - Learn hooks
// - Practice interviews
//
// After clicking "Add":
// - Learn hooks
// - Practice interviews
// - New task
//
// After toggling "Learn hooks":
// - ~~Learn hooks~~ (strikethrough)
// - Practice interviews
// - New task
Multiple State Variables vs Single Object
When to Use Multiple useState Calls
// GOOD: Independent pieces of state — separate them
function SignupForm() {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [agreed, setAgreed] = useState(false);
// Each state variable changes independently
// Easy to extract into custom hooks later
// No spread operator needed for updates
}
When to Use a Single Object
// GOOD: Related state that always changes together — group them
function MouseTracker() {
const [position, setPosition] = useState({ x: 0, y: 0 });
function handleMouseMove(e) {
// x and y always update together — they're conceptually one thing
setPosition({ x: e.clientX, y: e.clientY });
}
return <p>Mouse: ({position.x}, {position.y})</p>;
}
The rule of thumb:
- Separate state variables when they change independently (name, email, checkbox)
- Group state into an object when values are logically connected and always change together (x/y coordinates, width/height)
- If you have more than 4-5 related state variables, consider
useReducerinstead
The Stale State Problem
Stale state happens when a callback captures an old value of state through a JavaScript closure and uses it later, after the state has already changed.
Code Example 4: Stale State in setTimeout
function StaleCounter() {
const [count, setCount] = useState(0);
function handleClick() {
// BUG: `count` is captured at the moment handleClick runs
setTimeout(() => {
// By the time this runs (3 seconds later), `count` may have changed
// But this closure still sees the OLD value
setCount(count + 1);
}, 3000);
}
// If you click 5 times quickly:
// Each click captures count = 0, so all five timeouts set count to 1
// Result after 3 seconds: count = 1 (not 5)
function handleClickFixed() {
setTimeout(() => {
// FIX: Functional update always uses the latest state
setCount(prev => prev + 1);
}, 3000);
}
// If you click 5 times quickly with the fixed version:
// Each timeout runs prev => prev + 1 in sequence
// Result after 3 seconds: count = 5 (correct)
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>Add (buggy)</button>
<button onClick={handleClickFixed}>Add (fixed)</button>
</div>
);
}
// Clicking "Add (buggy)" 5 times rapidly:
// Output after 3s: Count: 1
//
// Clicking "Add (fixed)" 5 times rapidly:
// Output after 3s: Count: 5
Where stale state bites you:
setTimeout/setIntervalcallbacks- Event listeners added with
addEventListener - Promises and async functions
- Any closure that outlives the render it was created in
How to fix it:
- Functional updates —
setCount(prev => prev + 1)always gets the latest value - useRef — refs hold a mutable value that's always current (useful for reading state without updating it)
- useEffect cleanup — clean up intervals and listeners to avoid using stale references
Common Mistakes
Mistake 1: Mutating state directly instead of creating new references
const [user, setUser] = useState({ name: "Alice", age: 28 });
// WRONG — mutates the existing object, React won't re-render
user.name = "Bob";
setUser(user); // Same reference — no re-render
// RIGHT — create a new object
setUser(prev => ({ ...prev, name: "Bob" }));
Direct mutation is the single most common useState bug. React compares references, not deep values. Same reference means "nothing changed" to React, even if the data inside is different.
Mistake 2: Running expensive initialization without lazy init
// WRONG — JSON.parse runs on every render (wasted work)
const [data, setData] = useState(JSON.parse(localStorage.getItem("data")));
// RIGHT — function only runs on first render
const [data, setData] = useState(() => JSON.parse(localStorage.getItem("data")));
This can cause performance problems that are invisible in small apps but catastrophic in large ones. If the computation takes 50ms, and the component re-renders 20 times, you've wasted a full second.
Mistake 3: Using too many state variables for related data
// FRAGILE — these always change together, but they're separate
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [address, setAddress] = useState("");
const [city, setCity] = useState("");
// BETTER — group related state, or use useReducer for complex forms
const [formData, setFormData] = useState({
firstName: "", lastName: "", email: "",
phone: "", address: "", city: ""
});
// Update one field immutably
function handleChange(field, value) {
setFormData(prev => ({ ...prev, [field]: value }));
}
When you have more than 4-5 related state variables, grouping them or switching to useReducer makes the code easier to manage and less error-prone.
Interview Questions
Q: What is lazy initialization in useState, and when should you use it?
Lazy initialization means passing a function to
useStateinstead of a value:useState(() => expensiveCompute()). React calls the function only on the first render. Use it when the initial value requires an expensive computation like parsing JSON, reading from localStorage, or filtering a large dataset. Without it, the computation runs on every render even though the result is only used once.
Q: Why does mutating an object in state not trigger a re-render?
React uses
Object.iscomparison to detect state changes. When you mutate an object, the reference stays the same. React compares old reference to new reference, sees they're identical, and skips the re-render. You must create a new object (via spread or other methods) so React sees a different reference and knows to update.
Q: Show how you would update a deeply nested property in state immutably.
You spread at every level of nesting:
setUser(prev => ({ ...prev, address: { ...prev.address, city: "New City" } }));Each level creates a new object reference while preserving unchanged properties.
Q: What is the stale state problem and how do you fix it?
Stale state occurs when a closure (setTimeout, setInterval, event listener) captures a state value at the time it's created, then uses that outdated value later after state has changed. The fix is to use functional updates
setState(prev => prev + 1)so you always work with the latest state value, rather than the value from the render when the closure was created.
Q: When would you choose multiple useState calls over a single state object?
Use multiple
useStatecalls when state values are independent — they change at different times for different reasons (e.g., a name field, a checkbox, a counter). Use a single object when values are logically connected and always change together (e.g., x/y coordinates, form data for related fields). If you have more than 4-5 related values, consideruseReducerfor cleaner update logic.
Quick Reference — Cheat Sheet
+-----------------------------------+-------------------------------------------+
| Concept | Key Point |
+-----------------------------------+-------------------------------------------+
| Lazy initialization | useState(() => expensive()) — function |
| useState(() => value) | runs only on first render. |
+-----------------------------------+-------------------------------------------+
| Functional update | setState(prev => prev + 1) — always gets |
| setState(prev => ...) | the latest state. Use when new state |
| | depends on old state. |
+-----------------------------------+-------------------------------------------+
| Immutable object update | setObj(prev => ({ ...prev, key: val })) |
| | Spread to copy, then override. |
+-----------------------------------+-------------------------------------------+
| Immutable array: add | setArr(prev => [...prev, newItem]) |
| Immutable array: remove | setArr(prev => prev.filter(x => ...)) |
| Immutable array: update | setArr(prev => prev.map(x => ...)) |
+-----------------------------------+-------------------------------------------+
| Nested object update | Spread at every nesting level. |
| | { ...prev, nested: { ...prev.nested } } |
+-----------------------------------+-------------------------------------------+
| Multiple state vs object | Independent → separate useState calls. |
| | Coupled → single object or useReducer. |
+-----------------------------------+-------------------------------------------+
| Stale state | Closures capture old values. Fix with |
| | functional updates or useRef. |
+-----------------------------------+-------------------------------------------+
RULE: Never mutate state — always create a new reference.
RULE: If new state depends on old state → functional update.
RULE: If initial value is expensive → lazy initialization.
Previous: Lesson 2.5 — Immutability in State → Next: Lesson 3.2 — useEffect — Side Effects →
This is Lesson 3.1 of the React Interview Prep Course — 10 chapters, 42 lessons.