React Interview Prep
Hooks

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 useState is a trap most developers walk into? Can you describe the stale state problem without hesitating?

In interviews, useState isn'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


useState Deep Dive thumbnail


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).

useState Deep Dive visual 1


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

useState Deep Dive visual 2


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 useReducer instead

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 / setInterval callbacks
  • Event listeners added with addEventListener
  • Promises and async functions
  • Any closure that outlives the render it was created in

How to fix it:

  1. Functional updatessetCount(prev => prev + 1) always gets the latest value
  2. useRef — refs hold a mutable value that's always current (useful for reading state without updating it)
  3. useEffect cleanup — clean up intervals and listeners to avoid using stale references

useState Deep Dive visual 3


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.

// 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 useState instead 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.is comparison 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 useState calls 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, consider useReducer for 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.

On this page