React useState Deep Dive

You already met the basics in React State. Now we go a level deeper into the quiet useState traps that cause bugs once your app grows.

🐢 The initial value runs on every render

Here’s a trap that’s easy to miss. The thing you pass to useState is just normal code, and React runs that line on every render.

  • useState(someValue) evaluates someValue on every render, not only the first one.
  • For a plain number like useState(0), that’s totally fine. Computing 0 costs nothing.
  • But if you pass a heavy computation, like reading from local storage or building a big list, that heavy work runs again and again.
  • React then throws away the result on every render except the first. So you pay the cost for nothing.

So the problem is wasted work. The starting value is only used once, but your expensive code keeps running on every render.

This component looks innocent, but buildHugeList() runs on every single render even though its result is only needed once.

function expensiveInit() {
console.log("building the list..."); // runs on EVERY render
return Array.from({ length: 100000 }, (_, i) => i);
}
function List() {
const [items, setItems] = useState(expensiveInit()); // ❌ runs every render
return <p>{items.length} items</p>;
}

🦥 Lazy initial state: pass a function

The fix is small. Instead of passing the value, you pass a function that returns the value.

  • useState(() => expensiveInit()) is called lazy initial state.
  • React sees a function and runs it just once, to get the starting value.
  • On later renders React skips it completely. So your expensive code runs one time, not every time.
  • The result is exactly the same starting value. You only changed when the work happens.

Watch the difference. The wrong line runs the work every render. The right line wraps it in an arrow function, so it runs once.

// ❌ Wrong - expensiveInit() runs on every render
const [items, setItems] = useState(expensiveInit());
// ✅ Right - the function runs only on the first render
const [items, setItems] = useState(() => expensiveInit());

When to bother

Only reach for lazy initial state when the starting value is genuinely expensive to build, like parsing a big string, reading local storage, or creating a large array. For a plain useState(0) or useState(""), the function form adds noise for no gain.

🔁 The functional updater, briefly

You saw this in the updating-state lesson, so just a quick reminder. When your new state depends on the old state, pass a function to the setter, not a plain value.

  • setCount(prev => prev + 1) hands you the latest value as prev.
  • React reads prev from the most recent state, even if several updates are stacked in the same event.
  • A plain setCount(count + 1) reads count from the current render, which can be stale during a batch.

Don’t mix this up with lazy initial state. Both pass a function, but they do different jobs.

  • The function inside useState(() => ...) sets the starting value, once.
  • The function inside setCount(prev => ...) computes the next value from the previous one, every time you update.

This handler shows the updater form adding to the latest value safely.

function handleClick() {
setCount(prev => prev + 1); // builds on the most recent count
}

🧩 State replaces, it does not merge

If you came from class components, this one will surprise you. The useState setter does not merge like the old class setState. It replaces the whole value with whatever you pass.

  • Class setState({ age: 30 }) kept the other fields and only changed age.
  • The useState setter takes your new value and uses it as-is. So anything you left out is gone.
  • See, if your state is an object and you set only one field, the rest disappears.

This handler tries to update one field but accidentally throws away name and email.

const [user, setUser] = useState({ name: "Riya", email: "riya@mail.com", age: 29 });
function birthday() {
// ❌ Wrong - replaces the whole object, name and email are lost
setUser({ age: 30 });
// ✅ Right - copy the old fields first, then change age
setUser(prev => ({ ...prev, age: 30 }));
}

Spread the old state first

When state is an object or array, the setter overwrites everything. Copy the previous value with the spread operator (...prev) and then change just the field you want. Skip the copy and you’ll silently delete the other fields.

👥 Each component instance keeps its own state

Here’s something that feels like magic until you see it clearly. You can render the same component many times, and each copy remembers its own state.

  • State belongs to the instance, not to the component function itself.
  • Render <Counter /> three times and React keeps three separate counts.
  • Clicking one button changes only that one. The others stay exactly where they were.
  • React tracks each instance by its position in the tree, so it knows whose state is whose.

Here we put three counters on the page, and each one counts on its own.

function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
function App() {
return (
<div>
<Counter /> {/* its own count */}
<Counter /> {/* a separate count */}
<Counter /> {/* a separate count */}
</div>
);
}

So clicking the first button to 5 leaves the other two sitting at 0. Same component, three private piles of state.

⏭️ Setting the same value can skip the re-render

One last detail that helps you reason about performance. If you set state to a value that’s equal to what it already is, React can skip the re-render.

  • React compares the new value with the current one using Object.is, which is basically a strict equality check.
  • If they’re the same, React bails out. So it does not re-render the component.
  • This mostly matters for primitives like numbers, strings, and booleans, where equal means equal.
  • For objects and arrays it’s trickier. A brand new object is never Object.is-equal to the old one, so it always re-renders even when the fields match.

This handler sets the count to a value it already holds, so React does nothing.

const [count, setCount] = useState(5);
function noChange() {
setCount(5); // same value as now, so React skips the re-render
}

Don't rely on this for logic

The bailout is a small optimization React does for you, not a rule to build features on. Setting an object always makes a fresh reference, so it always re-renders even if the contents look identical. Treat the skip as a nice bonus, not a guarantee.

🧩 What You’ve Learned

  • ✅ The value you pass to useState runs on every render, so a heavy computation is wasted work
  • ✅ Lazy initial state, useState(() => expensiveInit()), runs the function only on the first render
  • ✅ Use useState(expensiveInit()) only when the value is cheap, never for expensive setup
  • ✅ The functional updater setCount(prev => prev + 1) is for the next value, while useState(() => ...) is for the starting value
  • ✅ The useState setter replaces the whole value, it does not merge like class setState
  • ✅ For object state, copy the old fields with ...prev before changing one field
  • ✅ Each component instance keeps its own independent state, even for the same component
  • ✅ Setting state to the same value can skip the re-render, thanks to React’s Object.is check

Check Your Knowledge

Test what you learned. Pick an answer for each question, then click Check.

  1. 1

    Why is useState(expensiveInit()) a problem?

    Why: The argument is plain code that React evaluates on every render. Passing the result of an expensive call runs that call each time, even though only the first value is used.

  2. 2

    What does useState(() => expensiveInit()) do differently?

    Why: When you pass a function, React treats it as lazy initial state and calls it once, on the first render only. That's how you avoid repeating expensive setup.

  3. 3

    Your state is { name: 'Riya', age: 29 } and you call setUser({ age: 30 }). What happens?

    Why: The useState setter replaces the value, it does not merge like class setState. You must spread the old fields first: setUser(prev => ({ ...prev, age: 30 })).

  4. 4

    You render the same Counter component three times. What is true about their state?

    Why: State belongs to each component instance, not to the component function. Three Counters mean three separate counts, tracked by position in the tree.

🚀 What’s Next?

You now understand useState deeply, from lazy setup to how each instance holds its own state. Next you’ll learn the other hook you’ll use constantly, the one for running code after render, like fetching data or setting up a timer.

React useEffect Hook

Share & Connect