LearnFrenzy
Sign in Take a free test

State and Events · Lesson 10 of 31

Updating Objects and Arrays in State

6 min read Updated Nov 8, 2026 Saurabh Samir

State can hold objects and arrays – a form, a cart, a list of todos. The one rule: never change them in place. Create a new object or array with the change and pass that to the setter. This lesson shows why, and gives you the patterns for every common update.

In this lesson

  • Why mutating state doesn't re-render
  • Updating objects with spread, including nested objects
  • Adding, removing and editing array items
  • A complete todo list

Mutation doesn't work

import { useState } from "react";

export default function App() {
  const [user, setUser] = useState({ name: "Asha", points: 10 });
  function addPointsWrong() {
    user.points += 5;          // ✗ mutates the existing object
    setUser(user);             // same object → React sees no change and skips the render
  }
  function addPointsRight() {
    setUser({ ...user, points: user.points + 5 });   // ✓ new object
  }
  return (
    <div>
      <p>{user.name}: {user.points} points</p>
      <button onClick={addPointsWrong}>Wrong +5</button>
      <button onClick={addPointsRight}>Right +5</button>
    </div>
  );
}

Result

Asha: 10 points
[Wrong +5] [Right +5]

› click “Wrong +5”
Asha: 10 points
[Wrong +5] [Right +5]

› click “Wrong +5”
Asha: 10 points
[Wrong +5] [Right +5]

› click “Right +5”
Asha: 25 points
[Wrong +5] [Right +5]

React compares the old and new state with Object.is. Passing the same object means "nothing changed", so the two wrong clicks show nothing – but they really did mutate the object, which is why the right click jumps straight to 25. Mutation creates exactly this kind of confusing, out-of-sync UI.

Updating objects

import { useState } from "react";

export default function App() {
  const [profile, setProfile] = useState({
    name: "Asha",
    email: "asha@example.com",
    address: { city: "Pune", pin: "411001" },
  });

  function handleChange(e) {
    const { name, value } = e.target;
    setProfile((p) => ({ ...p, [name]: value }));            // one handler for every top-level field
  }
  function moveToDelhi() {
    setProfile((p) => ({ ...p, address: { ...p.address, city: "Delhi", pin: "110001" } }));   // copy each level
  }

  return (
    <div>
      <input name="name" aria-label="Name" value={profile.name} onChange={handleChange} />
      <input name="email" aria-label="Email" value={profile.email} onChange={handleChange} />
      <button onClick={moveToDelhi}>Move to Delhi</button>
      <p>{profile.name} · {profile.email} · {profile.address.city} {profile.address.pin}</p>
    </div>
  );
}

Result

[Asha] [asha@example.com] [Move to Delhi]
Asha · asha@example.com · Pune 411001

› clear Name and type “Asha Verma”
[Asha Verma] [asha@example.com] [Move to Delhi]
Asha Verma · asha@example.com · Pune 411001

› click “Move to Delhi”
[Asha Verma] [asha@example.com] [Move to Delhi]
Asha Verma · asha@example.com · Delhi 110001

The spread { ...p } copies only one level. For nested data, copy every level on the path to the change. If your state is deeply nested, consider flattening it – or use a helper library like Immer, which lets you write "mutating" code that produces a new object.

Updating arrays

GoalAvoid (mutates)Use (returns a new array)
Addpush, unshift[...arr, item], [item, ...arr]
Removesplice, poparr.filter(x => x.id !== id)
Replace / editarr[i] = xarr.map(x => x.id === id ? { ...x, done: true } : x)
Sort / reversesort, reversetoSorted, toReversed
Insert at indexsplicetoSpliced(i, 0, item) or slices

A complete todo list

import { useState } from "react";

let nextId = 3;

export default function App() {
  const [todos, setTodos] = useState([
    { id: 1, text: "Learn useState", done: true },
    { id: 2, text: "Build a todo app", done: false },
  ]);
  const [text, setText] = useState("");

  function addTodo(e) {
    e.preventDefault();
    if (!text.trim()) return;
    setTodos([...todos, { id: nextId++, text: text.trim(), done: false }]);   // add
    setText("");
  }
  const toggle = (id) => setTodos(todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)));   // edit
  const remove = (id) => setTodos(todos.filter((t) => t.id !== id));                                  // remove

  const left = todos.filter((t) => !t.done).length;   // derived value: compute it, don't store it

  return (
    <div>
      <form onSubmit={addTodo}>
        <input aria-label="New todo" value={text} onChange={(e) => setText(e.target.value)} />
        <button>Add</button>
      </form>
      <ul>
        {todos.map((t) => (
          <li key={t.id}>
            <label>
              <input type="checkbox" checked={t.done} onChange={() => toggle(t.id)} /> {t.text}
            </label>
            <button onClick={() => remove(t.id)}>Delete {t.text}</button>
          </li>
        ))}
      </ul>
      <p>{left} left</p>
    </div>
  );
}

Result

[] [Add]
• [x] Learn useState [Delete Learn useState]
• [ ] Build a todo app [Delete Build a todo app]
1 left

› type “Write tests” into New todo
[Write tests] [Add]
• [x] Learn useState [Delete Learn useState]
• [ ] Build a todo app [Delete Build a todo app]
1 left

› click “Add”
[] [Add]
• [x] Learn useState [Delete Learn useState]
• [ ] Build a todo app [Delete Build a todo app]
• [ ] Write tests [Delete Write tests]
2 left

› click “Build a todo app”
[] [Add]
• [x] Learn useState [Delete Learn useState]
• [x] Build a todo app [Delete Build a todo app]
• [ ] Write tests [Delete Write tests]
1 left

› click “Delete Learn useState”
[] [Add]
• [x] Build a todo app [Delete Build a todo app]
• [ ] Write tests [Delete Write tests]
1 left

Don't store what you can compute

The "left" count is calculated from todos on each render instead of being kept in its own state. Duplicated state gets out of sync; derived values can't.

Practice

Try it yourself

  1. A cart holds [{ id, name, qty }]. Write an "increase" button per item and a "Clear cart" button.

    Show solution
    import { useState } from "react";
    
    export default function App() {
      const [cart, setCart] = useState([
        { id: "pen", name: "Pen", qty: 1 },
        { id: "book", name: "Notebook", qty: 2 },
      ]);
      const increase = (id) => setCart((c) => c.map((i) => (i.id === id ? { ...i, qty: i.qty + 1 } : i)));
      return (
        <div>
          <ul>
            {cart.map((i) => (
              <li key={i.id}>
                {i.name} × {i.qty} <button onClick={() => increase(i.id)}>More {i.name}</button>
              </li>
            ))}
          </ul>
          <p>Items: {cart.reduce((n, i) => n + i.qty, 0)}</p>
          <button onClick={() => setCart([])}>Clear cart</button>
        </div>
      );
    }

    Result

    • Pen × 1 [More Pen]
    • Notebook × 2 [More Notebook]
    Items: 3
    [Clear cart]
    
    › click “More Pen”
    • Pen × 2 [More Pen]
    • Notebook × 2 [More Notebook]
    Items: 4
    [Clear cart]
    
    › click “Clear cart”
    Items: 0
    [Clear cart]

Quick check

Why doesn't state.push(x); setState(state) re-render?

It's the same array reference, so React thinks nothing changed.

How do you change one item in an array?

map over it and return a new object for the matching item.

Does { ...obj } copy nested objects?

No – it's a shallow copy. Copy each nested level you change.

Summary

  • Treat state as immutable: always pass a new object or array to the setter.
  • Objects: spread and override; copy each nested level.
  • Arrays: spread to add, filter to remove, map to edit, toSorted to sort.

Finished this lesson?

Mark it complete to track your progress, then carry on with the next lesson.

Next lesson

Search LearnFrenzy

Esc

Quick links