Command Palette

Search for a command to run...

Hectal
PHASE 4Beginner ~21 min· topic 6 of 8

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 = 5 or arr.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 of obj'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.

Dot.jsxwhole filejsx
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.

Dot.jsxwhole filejsx
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.

Dot.jsxwhole filejsx
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.

Profile.jsxwhole filejsx
setPerson({
  ...person,                 // copy the top level
  artwork: {
    ...person.artwork,       // copy the level you change
    city: "Mumbai",          // the change
  },
});
Copy every level of a nested objectdiagram
Rendering diagram…

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.

Profile.jsxwhole filejsx
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. 1

    Fix the mutation

    In the first example, change handleMutate so it moves the dot down instead: setPosition({ ...position, y: position.y + 10 }). Predict the dot's movement and the logs.

  2. 2

    Change two levels at once

    In the nested example, add a button that changes both the artwork title to Lucky Nana and the email to niki@art.example. Which objects must be new? Check with the history comparison.

  3. 3

    Forget a level

    Write setPerson({ ...person, artwork: { city: "Mumbai" } }). Predict what happens to the title. (It disappears: the new artwork has only city.)

Code & diagrams

Mutating does nothing; a new object re-renders New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

render: x = 0
mutated: the object now says x = 10
render: x = 20
Updating a nested object New tab

The 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

Press Run to see the page.

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
Spread is shallow (plain JavaScript) New tab

`{ ...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 false

Break 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 });.

terminal
$ Preview
── what you'll see ──
The screen updates, so it looks fine. But the previous render's `person.artwork.city` now also says Mumbai, and any memoized child that received `person.artwork` doesn't re-render.

Break #2

Spread in the wrong order

Write setPerson({ email: newEmail, ...person }).

terminal
$ Preview
── what you'll see ──
The email never changes.

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 check memo, useMemo and 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 Proxy to 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.freeze to catch mutations: in strict-mode JavaScript (ES modules are always strict), assigning to a frozen object's field throws a TypeError.

  • ▸

    The React Compiler and eslint-plugin-react-hooks (react-hooks/immutability rule) flag mutations of props and state in your components.

Remember this

  1. 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. 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. 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. 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. Writing person.artwork.city = "Mumbai" would mutate an object that the previous render still uses.

  5. 5

    Why immutability pays off: old snapshots stay correct (undo/redo and time-travel debugging become easy), React and memo can 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. 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 the useImmer hook 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

01

Why doesn't position.x = 5; setPosition(position) re-render?

02

How do you update one field of an object in state? And one field of a nested object?

03

What does "spread is shallow" mean, and why does it matter?

04

What does Immer do, and when would you use it?

Practice

01

State is { name: "Asha", settings: { theme: "light", fontSize: 16 } }. Write a handler that switches the theme to dark without mutating.

02

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 useState calls 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-immer do.