Topic 4.6
Updating Objects in State
In one line
Treat objects in state as read-only: to change one, create a new object (usually with spread { ...obj, field: value }, copying every level you change in nested objects) and pass it to the setter, because React detects changes by comparing references.
Think of it like this
A signed form at the bank. You don't scribble over the original with a pen; you fill in a fresh copy with the change and hand that in. The clerk (React) only checks "is this a different sheet of paper?" If you hand back the same sheet, the clerk assumes nothing changed, even if you scribbled on it. Replace, don't scribble.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Mutation
- Changing an existing object or array in place, like
obj.x = 5orarr.push(1). - Immutable
- Never changed after creation. To "change" it, you make a new one.
- Reference
- Which object in memory a variable points to. Two variables can point to the same object.
- Object.is
- The comparison React uses for state. For objects it's true only if both are the very same object.
- Object spread
{ ...obj }: a new object with a copy ofobj's fields (one level deep).- Shallow copy
- A copy of the top level only; nested objects inside are still shared.
- Computed key
{ [name]: value }: an object field whose name comes from a variable.- Immer
- A library that turns "mutating" code on a draft into a new immutable object.
Step by step
01Store an object in state
A red dot's position is two numbers that always change together, so they live in one object.
const [position, setPosition] = useState({ x: 0, y: 0 });02The mutation trap
This changes the object React is holding and passes the same reference back. Object.is(old, new) is true, so React skips the render.
function handleMove() {
position.x = position.x + 10; // ❌ mutates the snapshot
setPosition(position); // same object: React bails out
}03Make a new object
Create a new object with the new values. A different reference tells React that something changed.
function handleMove() {
setPosition({ ...position, x: position.x + 10 }); // ✅ new object
}04Copy every level of a nested object
To change city inside artwork, make a new artwork and a new person that points to it. Untouched parts like contact are shared, not copied.
setPerson({
...person, // copy the top level
artwork: {
...person.artwork, // copy the level you change
city: "Mumbai", // the change
},
});05Or let Immer write the copies
With use-immer, you write a mutation on a draft and Immer creates the new nested objects for you. Install it with npm install immer use-immer.
import { useImmer } from "use-immer";
const [person, updatePerson] = useImmer({
name: "Asha",
artwork: { title: "Blue Nana", city: "Agra" },
});
function handleMove() {
updatePerson((draft) => {
draft.artwork.city = "Mumbai"; // looks like mutation, but only the draft changes
});
}Try it yourself
- 1
Fix the mutation
In the first example, change
handleMutateso it moves the dot down instead:setPosition({ ...position, y: position.y + 10 }). Predict the dot's movement and the logs. - 2
Change two levels at once
In the nested example, add a button that changes both the artwork title to
Lucky Nanaand the email toniki@art.example. Which objects must be new? Check with thehistorycomparison. - 3
Forget a level
Write
setPerson({ ...person, artwork: { city: "Mumbai" } }). Predict what happens to the title. (It disappears: the newartworkhas onlycity.)
Code & diagrams
Mutate changed the object but caused no render, so the dot stayed put. Copy then made a new object from the mutated one (10 + 10), so the dot jumps to 20: the hidden mutation leaked into the next state. Mutation bugs often show up later, somewhere else.
Preview
Console
Nothing logged yet.Expected console output
render: x = 0
mutated: the object now says x = 10
render: x = 20The old snapshot is untouched. Only the objects on the path to the change are new; `contact` is shared between the old and new state, which costs nothing.
Preview
Console
Nothing logged yet.Expected console output
old city: Hamburg | new city: Mumbai
same person object? false
same artwork object? false
same contact object? true`{ ...person }` copies the reference to `address`, not the object itself, so changing `copy.address.city` also changes `person`. Copy each nested level you change.
Console
(nothing was printed)Expected console output
Asha Delhi
true
Delhi Goa falseBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Mutate a nested field and set the top object
Write person.artwork.city = "Mumbai"; setPerson({ ...person });.
Break #2
Spread in the wrong order
Write setPerson({ email: newEmail, ...person }).
Myth vs fact
Myth
const makes the object in state immutable.
Fact
const only stops reassigning the variable. The object's fields can still be changed; immutability in React is a rule you follow, not something const enforces.
Myth
Copying objects on every change is slow.
Fact
Spread copies one level of references, which is very cheap. Unchanged nested objects are shared, not copied. The cost is almost always tiny compared with rendering.
Myth
You need a deep clone (structuredClone) for every update.
Fact
Deep cloning copies everything, breaks reference equality for parts that didn't change and is slower. Copy only the path you change.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
React compares state with
Object.is, the same checkmemo,useMemoand effect dependencies use. Immutable updates make all of them work with a cheap reference comparison: a new reference means "changed", the same reference means "unchanged". - ▸
Structural sharing (new objects only along the changed path) is exactly what persistent data structures and Immer do. Immer uses JavaScript
Proxyto record changes on the draft and, by default (in production too, since Immer 8), freezes the result so accidental mutations of it throw in strict code. - ▸
You can freeze state yourself in development with
Object.freezeto catch mutations: in strict-mode JavaScript (ES modules are always strict), assigning to a frozen object's field throws aTypeError. - ▸
The React Compiler and
eslint-plugin-react-hooks(react-hooks/immutabilityrule) flag mutations of props and state in your components.
Remember this
- 1
State can hold an object:
const [position, setPosition] = useState({ x: 0, y: 0 });. Technically you can change it in place (position.x = 5); this is called mutation. But you must not. In React, treat every object in state as read-only (immutable): never change its fields; create a new object instead. - 2
Why mutation doesn't re-render: when you call a setter, React compares the new value with the old one using
Object.is. For objects that compares references (is it the same object in memory?), not contents. If you mutate and pass the same object back (position.x = 5; setPosition(position)), React sees the same reference and skips the render. If you mutate without calling the setter at all, React doesn't even know. Either way the screen is wrong, and the old snapshot (Topic 4.4) has been changed behind React's back. - 3
Copy with spread. The object spread syntax
{ ...person }makes a new object with the same fields. Put the fields you change after the spread so they win:setPerson({ ...person, email: "asha@example.com" }). You can update several fields at once, and you can use a computed key to pick the field by name:setPerson({ ...person, [e.target.name]: e.target.value })(Phase 5 uses this for forms). - 4
Spread is shallow. It copies only one level. For a nested object like
person.artwork.city, you must copy each level on the path to the change:setPerson({ ...person, artwork: { ...person.artwork, city: "Mumbai" } }). Objects you didn't change are shared between the old and new state, which is cheap and safe because nobody mutates them. Writingperson.artwork.city = "Mumbai"would mutate an object that the previous render still uses. - 5
Why immutability pays off: old snapshots stay correct (undo/redo and time-travel debugging become easy), React and
memocan detect changes with a cheap reference check (Phase 16), and bugs where two components share one object and change each other disappear. Local mutation is fine: changing an object you just created in this handler, before it goes into state, harms nobody (const next = { ...person }; next.age = 31; setPerson(next);). - 6
Immer, if nesting gets painful. Deeply nested updates with spread get long. Immer is a small library that lets you write "mutating" code on a draft copy and produces a new immutable object for you:
updatePerson((draft) => { draft.artwork.city = "Mumbai"; })with theuseImmerhook from the use-immer package. Redux Toolkit uses Immer inside (Topic 15.4). Often a better fix is a flatter state shape (Topic 6.1).
Explain it without notes
Why doesn't position.x = 5; setPosition(position) re-render?
How do you update one field of an object in state? And one field of a nested object?
What does "spread is shallow" mean, and why does it matter?
What does Immer do, and when would you use it?
Practice
State is { name: "Asha", settings: { theme: "light", fontSize: 16 } }. Write a handler that switches the theme to dark without mutating.
Write a Score component with state { player: "Ravi", points: 0 } and a button that adds 5 points with an updater function.
Trade-offs
- ↔
Spread keeps you dependency-free but grows noisy with deep nesting; Immer reads like mutation but adds a library and a little magic. Flattening state often beats both.
- ↔
One object for related fields keeps them consistent; separate
useStatecalls avoid the spread when fields change independently.
Done when you can
I treat objects in state as read-only.
I can explain why mutation doesn't trigger a render.
I can update a field with
{ ...obj, field: value }.I can update a nested field by copying each level.
I know what Immer and
use-immerdo.