Updating Objects & Arrays

React · Lesson 11 of 29 6 min read Updated

State often holds more than a single number: a profile object with several fields, or a list of tasks. These values need extra care, because the obvious way to change them in JavaScript (user.city = "Pune" or items.push(x)) does not work with React.

In this lesson you will learn why you must never change state objects and arrays in place, and how to make updated copies with spread syntax and array methods for every common case: add, remove, edit, insert and sort.

Why mutation does not work

Mutating means changing an existing object or array in place. When you call a setter, React checks whether the new value is the same object as the old one, using Object.is. If you change the object in place and pass it back, it is still the same object, so React decides nothing changed and skips the re-render.

React JSXTry it yourself
import { useState } from "react";
import { createRoot } from "react-dom/client";

function Profile() {
  const [user, setUser] = useState({ name: "Ananya", city: "Kochi" });

  function moveWrong() {
    user.city = "Pune"; // mutation
    setUser(user); // same object: React skips the update
    console.log("Wrong way, same object?", Object.is(user, user));
  }

  function moveRight() {
    const updated = { ...user, city: "Pune" }; // new object
    console.log("Right way, same object?", Object.is(updated, user));
    setUser(updated);
  }

  return (
    <div>
      <p>{user.name} lives in {user.city}</p>
      <button onClick={moveWrong}>Move (mutate)</button>{" "}
      <button onClick={moveRight}>Move (copy)</button>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<Profile />);

Clicking "Move (mutate)" and then "Move (copy)" prints:

Output
Wrong way, same object? true
Right way, same object? false

After the first click the screen still says Kochi, even though the object secretly says Pune. The second click creates a new object, so React re-renders. Mutation also breaks features you will meet later, such as memo and the React Compiler, which rely on "new object means changed". So treat every object and array in state as read-only.

Updating objects with spread

The spread syntax ... copies all properties of an object into a new one. Properties written after the spread override the copied ones. This is perfect for forms with many fields, where one handler can update any field by its name:

React JSXTry it yourself
import { useState } from "react";
import { createRoot } from "react-dom/client";

function Admission() {
  const [form, setForm] = useState({
    name: "Rohan Mehta",
    course: "BCA",
    address: { city: "Rohtak", pin: "124001" },
  });

  function handleChange(e) {
    setForm({ ...form, [e.target.name]: e.target.value });
  }

  function handleCityChange(e) {
    setForm({ ...form, address: { ...form.address, city: e.target.value } });
  }

  return (
    <div>
      <input name="name" value={form.name} onChange={handleChange} />
      <input name="course" value={form.course} onChange={handleChange} />
      <input value={form.address.city} onChange={handleCityChange} />
      <p>
        {form.name} applied for {form.course} from {form.address.city} ({form.address.pin})
      </p>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<Admission />);

Two techniques appear here. [e.target.name]: e.target.value is a computed property name: the input's name attribute decides which field changes. And for the nested address object, you must copy each level you change: a new outer object and a new address object. Writing { ...form, city: "Pune" } would add a wrong top-level city field instead.

Deeply nested state gets verbose. If you find yourself spreading four levels deep, consider flattening the state shape, or use the small Immer library (useImmer), which lets you write mutating-looking code that produces copies for you.

Updating arrays

Arrays follow the same rule: produce a new array. Some array methods mutate, others return a new array. Prefer the ones on the right:

TaskAvoid (mutates)Prefer (returns a new array)
Addpush, unshift[...arr, item], [item, ...arr]
Removesplice, pop, shiftfilter
Replace or editarr[i] = xmap, with(i, x)
Insert in the middlesplicetoSpliced, or spread with slice
Sort or reversesort, reversetoSorted, toReversed

Here is a shopping list that adds, removes, toggles and sorts items without a single mutation:

React JSXTry it yourself
import { useState } from "react";
import { createRoot } from "react-dom/client";

let nextId = 4;

function ShoppingList() {
  const [items, setItems] = useState([
    { id: 1, name: "Basmati rice 5 kg", done: false },
    { id: 2, name: "Toor dal", done: true },
    { id: 3, name: "Amul butter", done: false },
  ]);
  const [text, setText] = useState("");

  function add() {
    if (!text.trim()) return;
    setItems([...items, { id: nextId++, name: text.trim(), done: false }]);
    setText("");
  }

  function toggle(id) {
    setItems(items.map((it) => (it.id === id ? { ...it, done: !it.done } : it)));
  }

  function remove(id) {
    setItems(items.filter((it) => it.id !== id));
  }

  function sortByName() {
    setItems(items.toSorted((a, b) => a.name.localeCompare(b.name)));
  }

  return (
    <div>
      <input value={text} onChange={(e) => setText(e.target.value)} placeholder="Add an item" />
      <button onClick={add}>Add</button> <button onClick={sortByName}>Sort A to Z</button>
      <ul>
        {items.map((it) => (
          <li key={it.id}>
            <label style={{ textDecoration: it.done ? "line-through" : "none" }}>
              <input type="checkbox" checked={it.done} onChange={() => toggle(it.id)} /> {it.name}
            </label>{" "}
            <button onClick={() => remove(it.id)}>Remove</button>
          </li>
        ))}
      </ul>
      <p>{items.filter((it) => !it.done).length} items left to buy</p>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<ShoppingList />);

Look closely at toggle. map builds a new array; for the matching item it returns a new object with done flipped, and every other item is returned unchanged. Editing it.done = !it.done inside map would mutate the original object, which is still shared with the old state.

Inserting and the copy trap

To insert an item at a position, toSpliced (ES2023) returns a new array and leaves the original alone:

React JSX
// insert "Jeera" at index 1
setItems(items.toSpliced(1, 0, { id: nextId++, name: "Jeera", done: false }));

A common trap is the shallow copy. [...items] makes a new array, but the objects inside are the same objects as before. Copying the array and then editing an object inside it still mutates state:

React JSX
const copy = [...items];
copy[0].done = true; // still mutates the original object!
setItems(copy);

Always create a new object for the item you change, as map with { ...it, done: true } does. For a full deep copy of plain data, structuredClone(value) exists, but copying only what changes is faster and clearer.

Common mistakes

  • push then set. items.push(x); setItems(items); passes the same array, so nothing re-renders. Use setItems([...items, x]).
  • Editing inside map. Return a new object for the changed item instead of assigning to it.
  • Forgetting nested levels. Each level you change needs its own spread.
  • Sorting state in place. items.sort() mutates state; use toSorted().
  • Stale updates in quick succession. When an update depends on the previous array, an updater works here too: setItems((prev) => [...prev, x]).

Key points

  • Never mutate objects or arrays in state; create new ones and pass them to the setter.
  • React compares the old and new value with Object.is; the same object means "no change".
  • Copy objects with { ...obj, field: value }, and spread every nested level you change.
  • Add with spread, remove with filter, edit with map, insert with toSpliced, sort with toSorted.
  • [...arr] is a shallow copy: objects inside are still shared.

Exercise

Create a Marks component whose state is an array of { id, subject, marks } for three subjects. Add a "+5 grace" button next to each subject that adds 5 marks (maximum 100) to that subject only, a "Remove" button, and a "Sort by marks" button that sorts from highest to lowest. Show the average below the list.

Show solution
React JSX
import { useState } from "react";
import { createRoot } from "react-dom/client";

function Marks() {
  const [rows, setRows] = useState([
    { id: 1, subject: "Maths", marks: 72 },
    { id: 2, subject: "Physics", marks: 97 },
    { id: 3, subject: "English", marks: 64 },
  ]);

  function grace(id) {
    setRows(rows.map((r) => (r.id === id ? { ...r, marks: Math.min(100, r.marks + 5) } : r)));
  }

  const average = rows.length ? Math.round(rows.reduce((s, r) => s + r.marks, 0) / rows.length) : 0;

  return (
    <div>
      <button onClick={() => setRows(rows.toSorted((a, b) => b.marks - a.marks))}>Sort by marks</button>
      <ul>
        {rows.map((r) => (
          <li key={r.id}>
            {r.subject}: {r.marks} <button onClick={() => grace(r.id)}>+5 grace</button>{" "}
            <button onClick={() => setRows(rows.filter((x) => x.id !== r.id))}>Remove</button>
          </li>
        ))}
      </ul>
      <p>Average: {average}</p>
    </div>
  );
}

createRoot(document.getElementById("root")).render(<Marks />);

map returns a new object only for the chosen subject, filter removes without mutating, and toSorted sorts a copy. The average is derived during render.

Quick Quiz

1. Why does user.city = "Pune"; setUser(user); not update the screen?

2. Which line correctly adds an item to an array in state?

3. How do you change pin inside form.address?

4. Which method removes an item without mutating the array?

Learn React with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call