Skip to main content

Updating Objects in State

You can put objects in state, but you must never edit them in place. The rule for this whole page is one sentence: treat every object in state as read-only, and to change it, make a new object and set state to that.

What's a mutation?

Some values are immutable, meaning they can't change. A number, string, or boolean is like this. When you do setX(5), the value 0 didn't turn into 5, you just swapped one fixed value for another.

primitive.jsx
const [x, setX] = useState(0);
setX(5); // x is now 5, but the number 0 itself never changed

Objects are different. Technically you can reach in and change their contents, and doing that is called a mutation.

mutation.jsx
const [position, setPosition] = useState({ x: 0, y: 0 });
position.x = 5; // this is a mutation, and in React you shouldn't do it

So while React state can hold mutable objects, you should treat them as if they were immutable, the same way you treat a number.

Treat state as read-only

Here's the bug that shows why. This dot is supposed to follow the pointer, but it never moves, because the handler mutates the existing position object instead of setting new state.

broken-dot.jsx
onPointerMove={(e) => {
position.x = e.clientX; // ❌ mutating the object from the last render
position.y = e.clientY; // ❌ React has no idea anything changed
}}

Changing position.x edits the object React gave you last render, but you never called the setter, so React doesn't know to re-render. The screen stays frozen. The fix is to build a brand new object and hand it to the setter.

fixed-dot.jsx
onPointerMove={(e) => {
setPosition({ // ✅ a new object, and React re-renders
x: e.clientX,
y: e.clientY,
});
}}

Deep dive: local mutation is fine. The rule only bans mutating objects already in state. Mutating a fresh object you just made yourself is completely okay, because no other code references it yet.

local-mutation.jsx
const nextPosition = {};
nextPosition.x = e.clientX; // fine, this object is brand new
nextPosition.y = e.clientY;
setPosition(nextPosition);

That's exactly equivalent to setPosition({ x: e.clientX, y: e.clientY }). Since nothing else points at nextPosition yet, changing it can't accidentally affect anything. You can even do this kind of local mutation while rendering.

Copying objects with the spread syntax

Usually a new object should keep most of the old fields and change just one. Writing every field by hand is tedious, so the object spread ... copies them for you.

spread-update.jsx
function handleFirstNameChange(e) {
setPerson({
...person, // copy every existing field
firstName: e.target.value, // then override just this one
});
}

The ...person copies all the current fields into the new object, and the line after it overrides the one you care about. Order matters: the override comes after the spread so it wins.

Using a single event handler for multiple fields

If a form has several inputs, you don't need one handler per field. Give each input a name that matches its state key, then use a computed property name [e.target.name] to update whichever field fired.

single-handler.jsx
function handleChange(e) {
setPerson({
...person,
[e.target.name]: e.target.value, // the [] makes the key dynamic
});
}

// <input name="firstName" value={person.firstName} onChange={handleChange} />
// <input name="lastName" value={person.lastName} onChange={handleChange} />
// <input name="email" value={person.email} onChange={handleChange} />

One handler now updates any field, based on the input's name.

Updating a nested object

Spread is shallow, it only copies one level deep. So when an object is nested, you have to copy at every level from the top down to the thing you're changing.

nested-state.jsx
const [person, setPerson] = useState({
name: "Niki de Saint Phalle",
artwork: {
title: "Blue Nana",
city: "Hamburg",
},
});

Mutating person.artwork.city = "New Delhi" is wrong for the same reason as before. Instead, spread each level:

nested-update.jsx
setPerson({
...person, // copy top-level fields (name)
artwork: { // replace artwork
...person.artwork, // copy its fields (title, city)
city: "New Delhi", // override the one you want
},
});

Deep dive: objects are not really nested. Code makes artwork look like it lives "inside" person, but at runtime there is no nesting. There are two separate objects, and person just holds a reference that points at the artwork object.

two-objects.jsx
let artwork = { title: "Blue Nana", city: "Hamburg" };
let person = { name: "Niki de Saint Phalle", artwork: artwork };

Because it's a reference, a second object could point at the same artwork. If it did, mutating artwork.city through one would change it for both, since they're literally the same object. Seeing state as "objects pointing at each other" rather than "nested" is what makes the spread-at-every-level rule feel obvious.

Write concise update logic with Immer

Copying at every level gets verbose fast for deep state. Immer lets you write code that looks like a mutation while still producing a new, immutable object under the hood. Install use-immer, swap useState for useImmer, and you get an updatePerson function that hands you a draft you can mutate freely.

with-immer.jsx
import { useImmer } from "use-immer";

const [person, updatePerson] = useImmer({
name: "Niki de Saint Phalle",
artwork: { title: "Blue Nana", city: "Hamburg" },
});

function handleCityChange(e) {
updatePerson((draft) => {
draft.artwork.city = e.target.value; // looks like a mutation, but it's safe
});
}

No spreading at each level, even for deep updates.

Deep dive: how does Immer work? The draft Immer gives you is a special object called a Proxy that records everything you do to it. That's why you can freely "mutate" it. Immer then looks at what you touched and produces a brand new object containing exactly those changes, leaving the original untouched.

Deep dive: why is mutating state not recommended? A few solid reasons. Debugging is easier, because if you never mutate, your old console.logs keep showing the real past state instead of being overwritten. Optimizations like memo work by checking prevObj === obj, which is only reliable if you never mutate in place. New features React is building assume state is treated like a snapshot. Requirement changes such as undo/redo or showing edit history are far easier when past copies of state are kept intact. And the implementation stays simpler, which is why React lets you drop any object into state, however large, without special handling. You can often get away with mutating, but the advice is firm: don't.

Recap

Treat all React state as immutable. Mutating an object in state won't trigger a render and quietly corrupts the previous render's snapshot, so instead of editing it, build a new object and pass it to the setter. Use the { ...obj, key: newValue } spread to copy an object and override a field, remembering that spread is shallow, so for nested objects you copy at every level from the top down. When that copying gets repetitive, reach for Immer to write mutation-style code that produces immutable updates for you.