Blog

Part 5 · Updating Objects and Arrays in React State

To change an object or array in state, make a new one. Learn why changing the old one doesn’t update the page, and how to add, remove and change items with spread, filter and map.

In Part 4 you stored values in state with useState. You called the setter with a new value, and React rendered the component again.

State can also hold an object, like a user with a name and an age. It can hold an array, like a list of tasks. These need one extra rule, and this part is about that rule.

The rule is short. Treat state as read-only. Don’t change the object or array that is in state. Make a new one, and pass the new one to the setter.

We’ll see why the rule exists, and then how to follow it for objects, nested objects, forms and arrays.

Try this first

Two lists of tasks start from the same array, start. The button should finish the first task in “Mine” only. Read the code, but don’t press Run yet.

import { useState } from 'react'

type Task = { id: number; title: string; done: boolean }

const start: Task[] = [
  { id: 1, title: 'Read', done: false },
  { id: 2, title: 'Swim', done: false },
]

function show(list: Task[]) {
  return list.map(t => t.title + (t.done ? ' (done)' : '')).join(', ')
}

export default function App() {
  const [mine, setMine] = useState(start)
  const [yours] = useState(start)

  function finishMyFirst() {
    const next = [...mine]
    next[0].done = true
    setMine(next)
  }

  return (
    <div>
      <p>Mine: {show(mine)}</p>
      <p>Yours: {show(yours)}</p>
      <button onClick={finishMyFirst}>Finish my first task</button>
    </div>
  )
}

A few things here are new:

  • type Task = { ... } gives a name to the shape of each task. Task[] means an array of tasks.
  • show makes one line of text from a list. map and join are explained later in this part.
  • [...mine] makes a new array with the same items as mine. The ... is called spread. We’ll look at it closely soon.

Make a guess. After you click, will “Yours” change?

Now press Run, and click.

Both lines now say “Read (done)”. “Mine” changed, so React did render again. We counted with React 19.3: one click ran App 2 more times with Strict Mode on, as in the playground. With it off, 1 more time. But “Yours” changed too, and nothing asked it to.

Keep this result in mind. To explain it, we need to look at how React checks for changes, and at what a copy really copies. We’ll come back to it in Arrays of objects.

Changing state in place

You saw this at the end of Part 4, with a score. Here it is with a user:

import { useState } from 'react'

export default function App() {
  const [user, setUser] = useState({ name: 'Ana', age: 12 })

  function changeName() {
    user.name = 'Ben'
    setUser(user)
  }

  return (
    <div>
      <p>Name: {user.name}</p>
      <p>Age: {user.age}</p>
      <button onClick={changeName}>Change the name to Ben</button>
    </div>
  )
}

Run it and click. The page still says “Name: Ana”. We counted: after the click, App ran 0 more times, with Strict Mode on or off.

As in Part 4, React compares the new value with the value it already has. If they are the same, React skips the update, and the page doesn’t change. (As Part 4 showed, React sometimes calls the component once more before it stops.)

To compare, React uses a JavaScript function called Object.is. React’s docs put it this way: React will ignore your update “if the next state is equal to the previous state”.

Object.is(5, 5) is true. For numbers and text, Object.is compares the values. For objects and arrays, it asks a different question: is this the very same object? It doesn’t look inside.

That matters because of how variables hold objects. A variable doesn’t hold its own copy of an object. It points at the object. Two variables can point at the same one:

const a = { name: 'Ana' }
const b = a
b.name = 'Ben'

console.log(a.name)                         // 'Ben'
console.log(Object.is(a, b))                // true
console.log(Object.is(a, { name: 'Ben' }))  // false

b = a doesn’t copy anything. Now a and b point at one object. So changing b.name changes a.name too. The last line is false, because { name: 'Ben' } makes a second object. It has the same field, but it is a different object.

Now look at the user example again. user.name = 'Ben' changed the object that was already in state. Then setUser(user) handed React that same object. Object.is gave true, so React skipped the update.

Changing an object or array in place like this is called mutation. To mutate something means to change it. That is the word React’s docs use, and you’ll hear it in interviews.

Maybe you wondered about const. user was made with const, so how could we change user.name? const only stops you from pointing the variable at a different value. It doesn’t stop you from changing the inside of the object it points at.

An everyday example

Think of a teacher who collects homework. To see if you handed in something new, she checks if it is a new sheet of paper. She doesn’t read it.

Say you take back your old sheet, change an answer and hand it in again. To her it is the same sheet, so she doesn’t mark it again.

The exact version

React doesn’t read your object to see what changed. It only asks if the next value is the same object as before. Object.is gives the answer.

The teacher example breaks in one place. The sheet still has your new answer on it. A teacher who reads it later, for some other reason, would see it. React does the same. Your change hides inside the object, and it shows up the next time the component renders for some other reason. Part 4 showed this: a different button made the hidden score appear.

React doesn’t stop you from changing state in place. It freezes elements in development (Part 1 showed the error), but not the objects you put in state. We checked: Object.isFrozen on a state object in React 19.3 gave false. Following the rule is up to you.

The fix: make a new object

The fix is to make a new object with the new name, and pass that to the setter.

1. State holds an object. The page shows Ana. 2. The code changes the old object: user.name = 'Ben'. 3. It passes that same object to setUser. 4. React compares the old value and the next value with Object.is. 5. Same object, so React skips the update. The page still shows Ana. the object in state { name: 'Ana' } changed in place a new object{ name: 'Ben' }the old one still says Ana setUser(user) Object.is(old, next) is true: the same object Name: AnaReact skipped the update Name: BenReact rendered again

Change the old object, or make a new one. Choose one, then press play or step through it.

Here is the figure in words. Use the figure’s buttons to choose a way and see each one.

Change the old object:

  1. State holds { name: 'Ana' }, and the page shows Ana.
  2. The code changes that object, so it now says { name: 'Ben' }.
  3. It passes the same object to setUser.
  4. React compares the old and next value with Object.is. The answer is true.
  5. React skips the update. The page still shows Ana.

Make a new object:

  1. State holds { name: 'Ana' }, and the page shows Ana.
  2. The code makes a new object, { name: 'Ben' }. The old one still says Ana.
  3. It passes the new object to setUser.
  4. React compares them with Object.is. The answer is false.
  5. React renders again. The page shows Ben.

In code, the fix is one line:

import { useState } from 'react'

export default function App() {
  const [user, setUser] = useState({ name: 'Ana', age: 12 })

  function changeName() {
    setUser({ ...user, name: 'Ben' })
  }

  return (
    <div>
      <p>Name: {user.name}</p>
      <p>Age: {user.age}</p>
      <button onClick={changeName}>Change the name to Ben</button>
    </div>
  )
}

Run it and click. The page shows “Name: Ben” and “Age: 12”.

Spread: copying an object

The three dots in { ...user, name: 'Ben' } are called spread. You met them in Part 3, passing props with {...props}. Inside { }, ...user means “copy every field of user into this new object”.

const user = { name: 'Ana', age: 12 }

const copy = { ...user }                // { name: 'Ana', age: 12 }, a new object
const next = { ...user, name: 'Ben' }   // { name: 'Ben', age: 12 }

copy has the same fields as user, but it is a new object. copy === user is false.

next copies both fields, then sets name again. When a field is set twice, the last one wins. So next has the new name and the old age.

The order matters. Here the spread comes last:

const user = { name: 'Ana', age: 12 }
const oops = { name: 'Ben', ...user }   // { name: 'Ana', age: 12 }

It sets the name to Ben first. Then ...user copies Ana’s name over it. In an editor, TypeScript marks this line as an error: name is set more than once. The playground doesn’t check types, so it shows no error here. Always put the spread first and your changes after it.

The old user doesn’t change in any of these. We checked: after making next, user was still { name: 'Ana', age: 12 }.

A form with several fields

A form often has many boxes, like a first name, a last name and an email. You can keep them all in one object, and use one small function to change any field.

This example uses onChange, which runs each time the user types in a box. e.target.value is the text that is in the box now. Part 6 explains events properly. For now, read it as “when the text changes, call update“.

import { useState } from 'react'

type Field = 'first' | 'last' | 'email'

export default function App() {
  const [form, setForm] = useState({ first: 'Ana', last: 'Silva', email: 'ana@mail.com' })

  function update(field: Field, value: string) {
    setForm({ ...form, [field]: value })
  }

  return (
    <div>
      <label>
        First name <input value={form.first} onChange={e => update('first', e.target.value)} />
      </label>
      <label>
        Last name <input value={form.last} onChange={e => update('last', e.target.value)} />
      </label>
      <label>
        Email <input value={form.email} onChange={e => update('email', e.target.value)} />
      </label>
      <p>Hello, {form.first} {form.last} ({form.email})</p>
    </div>
  )
}

Run it, and type in the first box. The line at the bottom changes as you type. The last name and the email stay.

Two new things are in update:

  • type Field = 'first' | 'last' | 'email' tells TypeScript that field must be one of those three words. In an editor, TypeScript then marks a typing mistake like update('frist', ...) as an error. The playground doesn’t check types, so it won’t.
  • [field]: value puts [ ] around the field’s name. It means “the field whose name is in the variable field“. When field is 'first', it is the same as first: value.

So { ...form, [field]: value } copies the whole form, then changes one field.

Nested objects: copy each level

An object can hold another object. Here person has an address, and the address has its own fields:

const person = {
  name: 'Ana',
  address: { city: 'Lima', street: 'Main Street' },
}

An object inside another object is called nested. Say Ana moves to Rome. You might try one spread, and then change the city:

const person = { name: 'Ana', address: { city: 'Lima', street: 'Main Street' } }
const copy = { ...person }
copy.address.city = 'Rome'

console.log(person.address.city)              // 'Rome'
console.log(copy.address === person.address)  // true

The old person changed too. Spread copies only one level. It made a new outer object, but copy.address points at the same address object as person.address. So changing the city changed the address that is in state. That is mutation again.

A copy like this is called a shallow copy: the top level is new, but anything inside is shared. A deep copy makes new objects at every level.

The fix is to make a new object at each level you change. Make a new address with the new city. Then make a new person with that new address:

import { useState } from 'react'

export default function App() {
  const [person, setPerson] = useState({
    name: 'Ana',
    address: { city: 'Lima', street: 'Main Street' },
  })

  function move() {
    setPerson({
      ...person,
      address: { ...person.address, city: 'Rome' },
    })
  }

  return (
    <div>
      <p>
        {person.name} lives on {person.address.street}, in {person.address.city}.
      </p>
      <button onClick={move}>Move to Rome</button>
    </div>
  )
}

Run it and click. Ana now lives in Rome, and the street is still Main Street.

Read the update from the inner part first. { ...person.address, city: 'Rome' } is a new address. { ...person, address: ... } is a new person that holds it. Parts you didn’t change are not copied. They are shared with the old object, and that is fine, because nobody changes them.

What about structuredClone?

JavaScript has a built-in function that makes a deep copy: structuredClone(person). You might think it saves you the work. It has two catches:

  • It copies everything, even the parts you didn’t change. We checked: after structuredClone, every inner object and array was a new one. In Part 20, you’ll see why that matters. React can skip work when a part you didn’t change is still the same object.
  • It can’t copy functions. An object with a function inside makes it throw an error called DataCloneError.

So copy the levels you change with spread. Sometimes state has many levels, and the spreads get long. Then a library called Immer can write the copies for you. React’s docs show how in their section on Immer. The playground can only load React, so we won’t use it here.

Arrays: the same rule

An array is a kind of object, so the same rule holds. Don’t change the array that is in state. Make a new array.

So don’t use some array methods you may know. A method is a function that belongs to a value, like items.push(...). Some methods change the array they are called on. Others leave it alone and give you back a new array.

push changes the array. So this does nothing, for the same reason as the user example:

items.push('Tea')
setItems(items)   // the same array: React skips the update

We checked: after a click that ran this, the component ran 0 more times.

Adding and removing

To add, spread the old array into a new one, and put the new item at the end: [...items, newItem]. Inside [ ], ...items means “every item of items“.

To remove, use filter. It makes a new array with only the items you keep. You give it a small function. It calls that function for each item, and keeps the item when the function returns true.

import { useState } from 'react'

type Item = { id: number; name: string }

let nextId = 3

export default function App() {
  const [items, setItems] = useState<Item[]>([
    { id: 1, name: 'Milk' },
    { id: 2, name: 'Bread' },
  ])
  const [text, setText] = useState('')

  function add() {
    setItems([...items, { id: nextId, name: text }])
    nextId = nextId + 1
    setText('')
  }

  function remove(id: number) {
    setItems(items.filter(item => item.id !== id))
  }

  return (
    <div>
      <input aria-label="New item" value={text} onChange={e => setText(e.target.value)} />
      <button onClick={add}>Add</button>
      <ul>
        {items.map(item => (
          <li key={item.id}>
            {item.name} <button onClick={() => remove(item.id)}>Remove {item.name}</button>
          </li>
        ))}
      </ul>
    </div>
  )
}

Run it. Type “Eggs” in the box and press Add. Then remove Bread.

A few things to notice:

  • type Item = { id: number; name: string } gives a name to the shape of each item. useState<Item[]> tells TypeScript that the state is an array of Items.
  • Each item has an id, a number no other item has. nextId lives outside the component, so it keeps counting up between renders.
  • items.filter(item => item.id !== id) keeps every item whose id is not the one we remove.
  • items.map(item => ( <li> ... </li> )) makes one <li> for each item. Each <li> gets a key, so React can tell the items apart. Without it, React warns in the Console. Part 8 explains lists and keys.

To add at the start instead of the end, put the new item first: [newItem, ...items].

Changing one item

To change one item, use map. Like filter, it makes a new array. It calls your function for each item, and the new array holds whatever your function returns. Return a new object for the item you change. Return the old item for all the others.

import { useState } from 'react'

type Task = { id: number; title: string; done: boolean }

export default function App() {
  const [tasks, setTasks] = useState<Task[]>([
    { id: 1, title: 'Read', done: false },
    { id: 2, title: 'Swim', done: false },
  ])

  function toggle(id: number) {
    setTasks(tasks.map(task => (task.id === id ? { ...task, done: !task.done } : task)))
  }

  return (
    <ul>
      {tasks.map(task => (
        <li key={task.id}>
          <button onClick={() => toggle(task.id)}>
            {task.done ? 'Done' : 'To do'}: {task.title}
          </button>
        </li>
      ))}
    </ul>
  )
}

Run it and click “Read”. It changes to “Done”. Click it again, and it changes back.

The line inside toggle does two copies. tasks.map(...) makes a new array. { ...task, done: !task.done } makes a new object for the one task that changes. !task.done means “the opposite of task.done“. The ? : operator from Part 1 picks which one to return.

Putting an item in the middle

To put an item at a certain place, use slice. slice copies a piece of an array and doesn’t change the array. Places in an array count from 0, so the first item is at place 0. items.slice(0, 1) is the items from place 0 up to, but not including, place 1. items.slice(1) is everything from place 1 to the end.

const items = ['Milk', 'Eggs']
const next = [...items.slice(0, 1), 'Bread', ...items.slice(1)]

console.log(next)   // ['Milk', 'Bread', 'Eggs']
console.log(items)  // ['Milk', 'Eggs']

Be careful: slice and splice have almost the same name. splice changes the array. We checked: splice(1, 1) on ['Milk', 'Bread', 'Eggs'] took out 'Bread' and left the array as ['Milk', 'Eggs'].

Sorting and reversing

sort and reverse change the array they are called on. Newer JavaScript has copies of them: toSorted and toReversed. They return a new array and leave the old one alone. All the main browsers have had them since July 2023.

import { useState } from 'react'

export default function App() {
  const [names, setNames] = useState(['Cara', 'Ana', 'Ben'])

  return (
    <div>
      <p>{names.join(', ')}</p>
      <button onClick={() => setNames(names.toSorted())}>Sort A to Z</button>
      <button onClick={() => setNames(names.toReversed())}>Reverse</button>
    </div>
  )
}

names.join(', ') makes one piece of text from the array, with , between the items.

In older code you’ll see [...names].sort(). The spread makes a copy first, and then sort changes the copy, not the state. That is fine too.

One thing to know about sorting: with no function, toSorted and sort compare items as text. We checked: [10, 9, 1].toSorted() gave [1, 10, 9]. For numbers, pass a function: toSorted((x, y) => x - y) gave [1, 9, 10].

Which methods to use

Here is the same list as a table. The left side changes the array. The right side gives you a new array. React’s docs have a table like this one.

To do this Avoid (changes the array) Use (returns a new array)
add push, unshift [...arr, x], [x, ...arr], concat
remove pop, shift, splice filter, slice
change an item splice, arr[0] = x map, with
sort or reverse sort, reverse toSorted, toReversed, or copy first
fill in values fill, copyWithin copy first, then change the copy

Three names in the table are new:

  • concat joins items onto the end of a copy. We checked: ['Milk'].concat('Tea', 'Eggs') gave ['Milk', 'Tea', 'Eggs'], and the old array stayed ['Milk'].
  • with is the copying form of arr[0] = x. ['Milk', 'Bread', 'Eggs'].with(1, 'Tea') gave ['Milk', 'Tea', 'Eggs'], and the old array didn’t change.
  • fill and copyWithin change the array in place, like sort. We checked: both returned the same array they were called on.

There is also toSpliced, the copying form of splice. ['Milk', 'Eggs'].toSpliced(1, 0, 'Bread') gave ['Milk', 'Bread', 'Eggs']. Like toSorted, with and toSpliced are newer methods, so you’ll see them less in older code.

Arrays of objects: a shallow copy shares the objects

Now we can explain “Try this first”.

finishMyFirst copied the array with spread, so the array was new. React rendered, because Object.is saw a different array. That part looked fine.

But the copy is shallow. Spread copies only the top level: the new array holds the same task objects as the old one. So next[0].done = true changed the task object that was in state. That same object was also in “Yours”, because both lists started from start. We checked: after const copy = [...list], copy[0] === list[0] was true.

The fix is the map and spread from before. It makes a new object for the task that changes:

import { useState } from 'react'

type Task = { id: number; title: string; done: boolean }

const start: Task[] = [
  { id: 1, title: 'Read', done: false },
  { id: 2, title: 'Swim', done: false },
]

function show(list: Task[]) {
  return list.map(t => t.title + (t.done ? ' (done)' : '')).join(', ')
}

export default function App() {
  const [mine, setMine] = useState(start)
  const [yours] = useState(start)

  function finishMyFirst() {
    setMine(mine.map(t => (t.id === 1 ? { ...t, done: true } : t)))
  }

  return (
    <div>
      <p>Mine: {show(mine)}</p>
      <p>Yours: {show(yours)}</p>
      <button onClick={finishMyFirst}>Finish my first task</button>
    </div>
  )
}

Now only “Mine” changes. “Swim” is not copied: the new array still holds the old Swim object. That is fine, because nothing changes it.

Why the rule helps, beyond re-rendering

Making new objects is a little more typing. Here is how it helps you.

Old values stay correct. Each render sees its own state, like a photo taken at that moment. Part 4 called this a snapshot. If you change an old object, you change that old photo too. If you never change it, an old value you logged or saved still says what it said then. React’s docs list this reason first: your old logs stay as they were.

Undo is easy. Undo means going back one step. You can keep the old arrays in a list, and go back to one. This example keeps every earlier list in past:

import { useState } from 'react'

export default function App() {
  const [items, setItems] = useState(['Milk'])
  const [past, setPast] = useState<string[][]>([])

  function add() {
    setPast([...past, items])
    setItems([...items, 'Item ' + (items.length + 1)])
  }

  function undo() {
    setItems(past[past.length - 1])
    setPast(past.slice(0, -1))
  }

  return (
    <div>
      <p>{items.join(', ')}</p>
      <button onClick={add}>Add</button>
      <button onClick={undo} disabled={past.length === 0}>
        Undo
      </button>
    </div>
  )
}

Click Add twice, then Undo. You get back “Milk, Item 2”.

string[][] means an array of arrays of text. past[past.length - 1] is the last list saved. past.slice(0, -1) is every list except the last one.

This works only because add never changes an old array. If add used push, every saved list in past would be the same array as items. We tried it: after two adds, Undo changed nothing on the page.

Checking for changes is fast. If you never mutate, then “same object” really does mean “nothing inside changed”. React relies on that. useState compares with Object.is, as you saw. In Part 20, React.memo compares each prop the same way to skip work.

Common mistakes

Forgetting the other fields

import { useState } from 'react'

export default function App() {
  const [user, setUser] = useState({ name: 'Ana', age: 12 })

  return (
    <div>
      <p>Name: {user.name}</p>
      <p>Age: {user.age}</p>
      <button onClick={() => setUser({ name: 'Ben' })}>Change the name</button>
    </div>
  )
}

The setter replaces the whole object. It doesn’t keep the old fields for you. So { name: 'Ben' } has no age at all.

TypeScript catches this. Its error says “Property ‘age’ is missing”. The playground doesn’t check types, so press Run and click. The page shows “Age:” with nothing after it. The fix is to spread first: setUser({ ...user, name: 'Ben' }).

Sorting the array that is in state

import { useState } from 'react'

export default function App() {
  const [names, setNames] = useState(['Cara', 'Ana', 'Ben'])

  return (
    <div>
      <p>{names.join(', ')}</p>
      <button onClick={() => setNames(names.sort())}>Sort A to Z</button>
    </div>
  )
}

Run it and click. Nothing happens. sort sorts the array in place and returns that same array. We checked: b.sort() === b was true. So the setter gets the old array, and React skips the update. Use names.toSorted().

Changing state inside an updater function

In Part 4 you met updater functions, like setItems(prev => ...). prev is the latest value, after any updates before it. It must not be changed either.

import { useState } from 'react'

export default function App() {
  const [items, setItems] = useState(['Milk'])

  function addTea() {
    setItems(prev => {
      prev.push('Tea')
      return [...prev]
    })
  }

  return (
    <div>
      <p>{items.join(', ')}</p>
      <button onClick={addTea}>Add tea</button>
    </div>
  )
}

Run it and click three times. You might expect three teas. With Strict Mode on, you get five.

React’s docs say that “In Strict Mode, React will call your updater function twice”. It does this in development, to find exactly this bug. This updater changes prev, so a second run adds a second tea. How many extra teas each click shows is a detail inside React. Don’t rely on it. What matters is that the list is wrong. With Strict Mode off, you’d get three teas, and the bug would hide until later.

The fix: setItems(prev => [...prev, 'Tea']). It never changes prev.

A list with no key

export default function App() {
  const fruits = ['Apple', 'Pear']
  return (
    <ul>
      {fruits.map(fruit => (
        <li>{fruit}</li>
      ))}
    </ul>
  )
}

The page looks right, but the Console shows a warning. In our run, its text was:

Each child in a list should have a unique "key" prop.

Check the render method of `App`. See https://react.dev/link/warning-keys for more information.

Give each <li> a key that is different for each item: <li key={fruit}>. Part 8 explains why.

Practice

Press Edit on the examples above and try these.

  1. In the form example, add a fourth field, city, with its own box. What do you need to change?
  2. In the nested example, add a button “Change the name to Ben” that changes only the name. How many spreads does it need?
  3. In the shopping list, add a button “Add to top” that puts the new item first.
  4. In the undo example, change add to use push: items.push('Item ' + (items.length + 1)), then setPast([...past, items]) and setItems(items). Click Add twice, then Undo. What happens, and why?
Answers
  1. Add city: 'Lima' (or any city) to the starting object, add 'city' to type Field, and add a box: <label>City <input value={form.city} onChange={e => update('city', e.target.value)} /></label>. update itself doesn’t change. That is the point of one function for all fields.
  2. One: setPerson({ ...person, name: 'Ben' }). The address doesn’t change, so the new person can share the old address object.
  3. setItems([{ id: nextId, name: text }, ...items]), then add one to nextId and clear the text, the same as in add.
  4. Each Add still shows the new item, because setPast gets a new array, so App renders. But Undo changes nothing on the page. Every list saved in past is the same array as items, and push changed that one array. There is no old list left to go back to.

Interview questions

Try to answer each one out loud before you open the answer.

Why shouldn’t you mutate state in React?

When you call the setter, React compares the next state with the old one using Object.is. Say you changed the old object and passed it back. It is the same object, so React skips the update. The page doesn’t change. Worse, the change hides inside the object and appears later, when something else makes the component render.

A strong answer adds the other reasons from React’s docs. Old values and old logs stay correct. Undo and history are easy, because old objects are kept as they were. And “same object” can safely mean “nothing changed”, which is what React.memo relies on. The docs also say that new React features “rely on state being treated like a snapshot”.

How does React decide that a state update changed nothing?

It compares the next value with the current one using Object.is. For numbers and text, that compares values. For objects and arrays, it is true only when both are the very same object. It doesn’t look inside. So setUser(user) after user.name = 'Ben' is skipped, and setUser({ ...user }) with no changes still renders again, because the object is new.

A strong answer mentions that Object.is is almost the same as ===. It differs only for NaN and for +0 and -0.

What is the difference between a shallow copy and a deep copy? Which one does spread make?

A shallow copy makes a new object at the top level, but anything inside it is shared with the original. A deep copy makes new objects at every level. Spread ({ ...obj } or [...arr]) makes a shallow copy. So for nested state, you spread each level you change: { ...person, address: { ...person.address, city: 'Rome' } }.

A strong answer says why shallow is usually what you want: parts you didn’t change stay the same objects. It names structuredClone as a deep copy, and its catches. It copies everything, even the parts you didn’t change, and it throws on functions.

Which array methods change the array, and what do you use instead in React state?

push, pop, shift, unshift, splice, sort and reverse change the array in place. So does setting one place, like arr[0] = x. Instead, use the ones that return a new array. To add, use spread ([...arr, x]) or concat. To remove, use filter or slice. To change items, use map. To sort or reverse, use toSorted or toReversed. Or copy first with [...arr], then sort the copy.

A strong answer warns that sort returns the same array, so setList(list.sort()) does nothing visible.

How do you change one object inside an array in state?

With map and spread: setTasks(tasks.map(t => (t.id === id ? { ...t, done: true } : t))). The array is new, the changed item is a new object, and every other item stays the same object.

A strong answer explains the trap: const next = [...tasks]; next[0].done = true makes a new array but still changes the old object, because the copy is shallow.

Is it ever fine to mutate an object in React?

Yes, when the object is new and not in state yet. React’s docs call it “local mutation”. Say a function makes a new array and fills it with push. Then it passes that array to the setter. That is fine, because the function made the array itself. A copy like const next = [...list] can also be sorted in place. The rule is about objects that are already in state, or that came from props.

Sources

  • Updating Objects in State, react.dev: “treat state as read-only”, spread being shallow, nested updates, local mutation, Immer, and the reasons against mutation (old logs, undo, fast checks).
  • Updating Arrays in State, react.dev: the table of methods to avoid and to use, slice vs splice, sorting a copy, and the shallow copy trap in arrays of objects.
  • useState, react.dev: the Object.is check that skips an update, and “In Strict Mode, React will call your updater function twice”.
  • memo, react.dev: React.memo compares each prop with Object.is.
  • Rendering Lists, react.dev: the missing key warning.
  • MDN: Object.is(), Spread syntax (a shallow copy), Array.prototype.toSorted() (in all main browsers since July 2023), structuredClone() and the structured clone algorithm (functions throw DataCloneError).
  • The render counts, the page text after each click, the five teas, the undo results and every “we checked” above come from running React 19.3.0 for this post.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.