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.showmakes one line of text from a list.mapandjoinare explained later in this part.[...mine]makes a new array with the same items asmine. 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.
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:
- State holds
{ name: 'Ana' }, and the page shows Ana. - The code changes that object, so it now says
{ name: 'Ben' }. - It passes the same object to
setUser. - React compares the old and next value with
Object.is. The answer istrue. - React skips the update. The page still shows Ana.
Make a new object:
- State holds
{ name: 'Ana' }, and the page shows Ana. - The code makes a new object,
{ name: 'Ben' }. The old one still says Ana. - It passes the new object to
setUser. - React compares them with
Object.is. The answer isfalse. - 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 thatfieldmust be one of those three words. In an editor, TypeScript then marks a typing mistake likeupdate('frist', ...)as an error. The playground doesn’t check types, so it won’t.[field]: valueputs[ ]around the field’s name. It means “the field whose name is in the variablefield“. Whenfieldis'first', it is the same asfirst: 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 ofItems.- Each item has an
id, a number no other item has.nextIdlives 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 akey, 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:
concatjoins items onto the end of a copy. We checked:['Milk'].concat('Tea', 'Eggs')gave['Milk', 'Tea', 'Eggs'], and the old array stayed['Milk'].withis the copying form ofarr[0] = x.['Milk', 'Bread', 'Eggs'].with(1, 'Tea')gave['Milk', 'Tea', 'Eggs'], and the old array didn’t change.fillandcopyWithinchange the array in place, likesort. 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.
- In the form example, add a fourth field,
city, with its own box. What do you need to change? - In the nested example, add a button “Change the name to Ben” that changes only the name. How many spreads does it need?
- In the shopping list, add a button “Add to top” that puts the new item first.
- In the undo example, change
addto usepush:items.push('Item ' + (items.length + 1)), thensetPast([...past, items])andsetItems(items). Click Add twice, then Undo. What happens, and why?
Answers
- Add
city: 'Lima'(or any city) to the starting object, add'city'totype Field, and add a box:<label>City <input value={form.city} onChange={e => update('city', e.target.value)} /></label>.updateitself doesn’t change. That is the point of one function for all fields. - One:
setPerson({ ...person, name: 'Ben' }). The address doesn’t change, so the new person can share the old address object. setItems([{ id: nextId, name: text }, ...items]), then add one tonextIdand clear the text, the same as inadd.- Each Add still shows the new item, because
setPastgets a new array, soApprenders. But Undo changes nothing on the page. Every list saved inpastis the same array asitems, andpushchanged 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,
slicevssplice, sorting a copy, and the shallow copy trap in arrays of objects. - useState, react.dev: the
Object.ischeck that skips an update, and “In Strict Mode, React will call your updater function twice”. - memo, react.dev:
React.memocompares each prop withObject.is. - Rendering Lists, react.dev: the missing
keywarning. - 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.