Himanshu SharmaSoftware engineer
Let’s talk

React · Lesson 16 of 38

Objects and arrays in state

Update objects and arrays held in React state without mutating them, using spread, map and filter, including nested objects, and see why mutation fails.

20 minState and eventsUpdated 24 October 2026

By the end of this lessonAdd, remove, change and reorder items in array state and update object state, always by creating a new copy.

State can hold objects and arrays, and real apps need that all the time: a user profile, a list of tasks, a shopping cart. But there is a rule that surprises beginners: do not change state objects and arrays in place. Instead, make a new object or array with the change, and give that to the setter. This lesson shows the patterns.

Why you must not mutate

React decides whether to re-render by comparing the new state with the old using Object.is. For objects and arrays that means same object in memory, not "same contents". If you change an array in place and pass the same array back, React sees the same object and concludes nothing changed:

jsx
import { useState } from "react";

function App() {
  const [items, setItems] = useState(["Milk"]);

  function addBad() {
    items.push("Eggs");        // mutates the existing array
    setItems(items);           // same array object: React skips the render
    console.log("array now has", items.length, "items");
  }

  return (
    <div>
      <button className="bad" onClick={addBad}>Add (mutating)</button>
      <p>On screen: {items.join(", ")}</p>
    </div>
  );
}

export default App;
result
Add (mutating)
On screen: Milk
CONSOLE
array now has 2 items
array now has 3 items

The data changed (the log says 3 items after two clicks), yet the screen still shows only "Milk". Worse, the mutation can show up later, when some unrelated state change triggers a render, which makes the bug hard to trace. Mutation also breaks features that rely on the old value staying intact. Now the correct version:

jsx
import { useState } from "react";

function App() {
  const [items, setItems] = useState(["Milk"]);

  function addGood() {
    setItems([...items, "Eggs"]);   // a new array: the old items plus one more
  }

  return (
    <div>
      <button className="good" onClick={addGood}>Add (copying)</button>
      <p>On screen: {items.join(", ")}</p>
    </div>
  );
}

export default App;
result
Add (copying)
On screen: Milk, Eggs, Eggs

Two clicks, two "Eggs". The rule to remember: state is read-only; replace it.

Updating objects

For an object, copy its properties with the spread operator and override what changes:

jsx
import { useState } from "react";

function App() {
  const [user, setUser] = useState({ name: "Asha", age: 30, city: "Pune" });

  return (
    <div>
      <p>{user.name}, {user.age}, from {user.city}</p>
      <button className="city" onClick={() => setUser({ ...user, city: "Delhi" })}>Move to Delhi</button>
      <button className="birthday" onClick={() => setUser(u => ({ ...u, age: u.age + 1 }))}>Birthday</button>
    </div>
  );
}

export default App;
result
Asha, 31, from Delhi
Move to DelhiBirthday

{ ...user, city: "Delhi" } reads as "everything in user, but with city replaced". Properties written after the spread override the copied ones. The second button uses an updater function (note the round brackets around the object literal, so that the arrow function returns it).

Nested objects

Spread makes a shallow copy: nested objects are still shared with the old one. To change something deep inside, copy every level on the path:

jsx
import { useState } from "react";

function App() {
  const [person, setPerson] = useState({
    name: "Asha",
    address: { city: "Pune", pin: "411001" },
  });

  function moveToDelhi() {
    setPerson({
      ...person,                              // copy the person
      address: { ...person.address, city: "Delhi" },   // copy the address, change the city
    });
  }

  return (
    <div>
      <p>{person.name} lives in {person.address.city} ({person.address.pin})</p>
      <button onClick={moveToDelhi}>Move</button>
    </div>
  );
}

export default App;
result
Asha lives in Delhi (411001)
Move

The pin code survived because ...person.address copied it. If the nesting gets deep, the repetition becomes tiresome, which is one more reason to keep state flat (the next lesson) or to use the small library Immer (use-immer), which lets you write "mutating" code that it turns into copies. Learn the plain way first.

Updating arrays

Array methods fall into two groups: those that mutate (avoid them on state) and those that return a new array (use these):

Task Avoid (mutates) Use (returns a new array)
add at the end push [...items, item]
add at the start unshift [item, ...items]
remove splice, pop, shift filter
replace or change items[i] = x map
sort sort [...items].sort(...) or toSorted
reverse reverse [...items].reverse() or toReversed
insert in the middle splice [...items.slice(0, i), item, ...items.slice(i)]

A complete example with add, remove and toggle on a list of objects:

jsx
import { useState } from "react";

let nextId = 3;

function App() {
  const [tasks, setTasks] = useState([
    { id: 1, text: "Learn state", done: false },
    { id: 2, text: "Write code", done: false },
  ]);
  const [text, setText] = useState("");

  function addTask() {
    if (text.trim() === "") return;
    setTasks([...tasks, { id: nextId++, text: text.trim(), done: false }]);
    setText("");
  }

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

  function removeTask(id) {
    setTasks(tasks.filter(task => task.id !== id));
  }

  return (
    <div>
      <input value={text} onChange={e => setText(e.target.value)} placeholder="New task" />
      <button className="add" onClick={addTask}>Add</button>
      <ul>
        {tasks.map(task => (
          <li key={task.id}>
            <input type="checkbox" checked={task.done} onChange={() => toggleTask(task.id)} />{" "}
            <span style={{ textDecoration: task.done ? "line-through" : "none" }}>{task.text}</span>{" "}
            <button className="remove" onClick={() => removeTask(task.id)}>x</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;
result
Add
 Learn state x
 Buy milk x
 Call Ravi x

In the test I added two tasks, ticked the first one and removed the second. Study the three update functions:

  • Add: [...tasks, newTask] copies the old list and appends the new object. The id comes from a counter kept outside the component, so it does not change when the component re-renders (in a real app you would use crypto.randomUUID() or the id your server returns).
  • Toggle: map goes through the list and returns the same task for most items, and a new object ({ ...task, done: !task.done }) for the one that changed. Items that did not change are reused, which is cheap.
  • Remove: filter keeps every task whose id does not match.

The input uses state too (text with value and onChange); that is a controlled input, the topic of the Forms lessons.

Inserting and reordering

jsx
import { useState } from "react";

function App() {
  const [letters, setLetters] = useState(["d", "a", "c"]);

  function insertB() {
    setLetters([...letters.slice(0, 1), "b", ...letters.slice(1)]);   // after the first item
  }

  return (
    <div>
      <p>{letters.join(" ")}</p>
      <button className="insert" onClick={insertB}>Insert b after first</button>
      <button className="sort" onClick={() => setLetters([...letters].sort())}>Sort</button>
      <button className="reverse" onClick={() => setLetters([...letters].reverse())}>Reverse</button>
    </div>
  );
}

export default App;
result
d c b a
Insert b after firstSortReverse

Start d a c; insert gives d b a c; sort gives a b c d; reverse gives d c b a. The copies [...letters] are what keep each step safe: calling letters.sort() directly would sort the state array in place.

Why immutability is worth it

Beyond making React notice changes, never mutating state brings real benefits: past states stay intact (you can implement undo by keeping old arrays), React can skip work by comparing identities, components that receive an object as a prop can tell cheaply whether it changed, and bugs where two places accidentally share and change the same array disappear.

Note that this applies to values in state. You may build and mutate a new array during rendering (a const result = [] that you push into and return); the earlier lesson on pure components explains that creating something inside the render is fine.

Common mistakes

  • Using push, splice, sort or reverse directly on a state array.
  • Assigning to a property of a state object: user.city = "Delhi"; setUser(user).
  • Forgetting that spread is shallow, and mutating a nested object.
  • Forgetting the brackets around an object literal returned from an arrow function: u => { ...u } is a syntax error; write u => ({ ...u }).
  • Using the array index as the identity for items you add, remove or reorder; use a stable id.
  • Creating the id inside the component with a plain counter variable, which resets every render. Use a counter outside the component, state, or a UUID.

Exercise

  1. Add an "Edit" feature to the task list: clicking a button next to a task changes its text to upper case, using map.
  2. Add a "Clear done" button that removes all completed tasks with one filter.
  3. Add a "Move to top" button on each task using filter and a spread.
  4. Build a profile with a nested address, and add a button to change only the pin code without losing the city.

Key points

  • Treat state as read-only: to change an object or array, build a new one and pass it to the setter.
  • For objects use spread ({ ...user, city: 'Delhi' }); for nested objects, copy each level you change.
  • For arrays, add with [...items, item], remove with filter, change with map, and sort or reverse a copy.
  • React skips the update if you pass the same object back, so a mutated object with the same identity never shows on screen.

Check yourself

1. Why does items.push('x'); setItems(items) not update the screen?
2. Which line adds a task to the end of an array state?
3. How do you remove the item with id 3 from array state?
4. What does { ...user, city: 'Delhi' } create?