We’ve all been there: you tweak a field, call setPerson(person), and wait for the UI to update. Nothing happens. You reload the tab, check DevTools, and wonder if you found a weird bug in React.

React is doing exactly what it was designed to do. You modified the original object in place, so React had no clue anything changed. Let’s walk through why this happens under the hood and look at the cleanest ways to handle it.

  1. Scan your component for any direct mutations like state.property = newValue.
  2. Swap those out for a fresh copy: setState({ ...state, property: newValue }).
  3. If it’s nested, make sure you spread down through every layer: setState({ ...state, nested: { ...state.nested, key: newValue } }).
  4. For arrays, lean on non-mutating helpers like map(), filter(), or spread. Stay away from push() and splice() on existing state.

Why React Checks References, Not Values

Under the hood, React checks state changes using Object.is(), which works just like strict equality (===) for objects. It doesn't crawl through your data structure comparing every individual key. Doing a deep property-by-property comparison on every single render cycle would tank performance across any decent-sized tree.

Instead, React looks at the memory address - the object’s reference. When you modify an existing object and hand it back to the setter, that memory pointer hasn't changed at all. React looks at the old reference and the new reference, sees they match, and simply bails out of rendering.

JavaScript

code5 lines
const [person, setPerson] = useState({ name: 'Alice', age: 25 });

// This modifies the exact object React is already holding
person.age = 26;
setPerson(person); // Same reference in memory, so React ignores the call

This isn't an oversight; it's a deliberate design trade-off that keeps React blazing fast. The flip side is that making sure objects get copied cleanly is our responsibility.

The Mutation Trap: What's Actually Happening

Here is the subtle gotcha that catches people off guard: you can alter an object’s fields all you want in your event handler, and React will remain completely oblivious. Your in-memory state is technically sitting there with the new values, but because the container itself never changed identity, no render cycle ever kicks off.

The golden rule here is simple: whenever you talk to a setter, hand it a freshly minted object.

Fix 1: Spread Operator

The spread operator gives you a clean, shallow copy of your object, which creates a brand-new reference in memory:

JavaScript

code4 lines
const [person, setPerson] = useState({ name: 'Alice', age: 25 });

// Creating a brand-new object literal triggers a render
setPerson({ ...person, age: 26 });

Writing { ...person, age: 26 } unpacks the existing keys into an entirely new container and overrides age. React sees a new memory address, registers that something actually shifted, and runs the render cycle.

Fix 2: Nested Objects

Nested state is where this gets slightly trickier. A standard shallow spread only creates a new container at the very top level. Any objects nested inside still point to the exact same memory locations as before.

JavaScript

code11 lines
const [user, setUser] = useState({
  name: 'Alice',
  address: { city: 'Berlin', zip: '10115' }
});

// Bug: modifying city in place leaves the inner address reference untouched
user.address.city = 'Munich';
setUser({ ...user }); // New root object, but address is still the old pointer

// Safe: spread every single level you need to touch
setUser({ ...user, address: { ...user.address, city: 'Munich' } });

You have to unpack every layer between the root and the field you want to edit. As you can guess, this gets wordy fast, which is why libraries like Immer exist (we'll look at that in a second).

If you just need a clean, full duplicate without manually writing nested spreads, you can also use structuredClone():

JavaScript

code3 lines
const next = structuredClone(user);
next.address.city = 'Munich';
setUser(next);

structuredClone is built right into modern JavaScript runtimes (Node 17+, Chrome 98+, Safari 15.4+, and Firefox 94+). It doesn't play well with methods, DOM nodes, or class instances, but for straightforward JSON-like state objects, it’s remarkably painless.

Fix 3: Arrays Need the Same Treatment

Arrays in JavaScript are just objects under the hood, so the exact same memory rules apply: don't mutate the array in place, return a fresh one.

JavaScript

code8 lines
const [items, setItems] = useState([1, 2, 3]);

// Avoid: push alters the current array reference
items.push(4);
setItems(items); // Same pointer, so no UI update happens

// Better: spreading creates an entirely new array
setItems([...items, 4]);

Here's a quick cheat sheet for day-to-day array updates:

Operation Clean approach
Add an item [...items, newItem]
Remove an item items.filter(i => i.id !== targetId)
Update an item items.map(i => i.id === targetId ? { ...i, ...updates } : i)
Reorder / Sort [...items].sort(compareFn) (sort mutates, so spread first)

Any array method that returns a fresh instance (map, filter, slice, concat) works great with state. Methods that modify the array in place (push, pop, splice, sort, reverse) will leave you chasing phantom bugs.

Fix 4: Local Mutation Before setState Is Fine

React doesn't care if you mutate objects in your code; it only cares about the specific object instance currently stored in state. If you instantiate an empty object inside a handler and tweak it locally before handing it off, that's totally safe:

JavaScript

code6 lines
const handleClick = (e) => {
  const next = {}; // Brand-new object created right here
  next.x = e.clientX;
  next.y = e.clientY;
  setPosition(next); // Clean reference, component rerenders as expected
};

Functionally, this does the exact same job as an object literal or spread; it’s just styled imperatively. As long as you aren’t touching the object already held in state, you’re in the clear.

When Spreading Gets Verbose: Immer

I ran headfirst into this while wiring up a nested settings panel a while back. Writing three tiers of object spreading just to flip a single boolean stops feeling like good architecture and starts feeling like an unreadable mess. That’s usually the moment to pull in Immer.

When you're dealing with deeply nested trees, plain spread syntax becomes a chore to read and maintain:

JavaScript

code10 lines
setConfig({
  ...config,
  server: {
    ...config.server,
    database: {
      ...config.server.database,
      port: 5433
    }
  }
});

Immer gets around this by letting you write direct mutations against a sandbox copy while quietly handling the immutable cloning behind the scenes:

JavaScript

code7 lines
import { useImmer } from 'use-immer';

const [config, updateConfig] = useImmer(initialConfig);

updateConfig(draft => {
  draft.server.database.port = 5433; // Looks like direct mutation, but totally safe
});

Under the hood, Immer relies on JavaScript Proxy objects to record your modifications to the draft. Once the callback wraps up, it compiles a fresh, structurally shared copy with a new reference for React.

A quick npm install use-immer gets you going. It really shines in large forms, multi-step wizards, or state trees that mimic document editors.

useReducer for Complex State Logic

When your updates depend on multiple conditions or you have several fields that need to change in sync, switching from multiple useState calls to useReducer makes life a lot simpler:

JavaScript

code13 lines
function reducer(state, action) {
  switch (action.type) {
    case 'update_age':
      return { ...state, age: action.age }; // Still need a fresh object here
    case 'reset':
      return initialState;
    default:
      return state;
  }
}

const [person, dispatch] = useReducer(reducer, initialState);
dispatch({ type: 'update_age', age: 26 });

The underlying rule doesn't change here - your reducer must return a new reference, never a mutated version of state. The real win is keeping your business logic in one predictable place rather than scattering it across handlers.

If you’re building things like theme toggles and UI modes or components with interdependent settings, useReducer keeps your component code clean and your state transitions obvious.

Direct mutation bugs are notoriously annoying because they fail silently - no crash, no red errors in the console, just a UI that refuses to budge. Once you get used to thinking in memory references rather than values, you’ll spot and avoid these instantly.