Skip to content
aviral gupta

// B3.5 · ~30 min · Beginner

Updating objects and arrays in state

After this lesson you can update objects and arrays in state by making new ones: spread for objects, and spread, filter, map and slice for arrays.

Lesson 5 of 6 in B3 Adding interactivity

You will be able to

  • Replace an object in state with a copy made by spread, also a nested one, and handle many fields with one handler
  • Add, remove, replace, insert and reorder array items in state without mutating the array
  • Update one object inside an array with map, so that no other list or render sees the change
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B3.4: state as a snapshot. The handler sets tags twice, each time with a new array built from tags. You click once. What does the button show?

    function Tags() {
      const [tags, setTags] = useState(['a']);
    
      function handleClick() {
        setTags([...tags, 'b']);
        setTags([...tags, 'c']);
      }
    
      return <button onClick={handleClick}>{tags.join(', ')}</button>;
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. The Move button changes position.x to 5. You click it once. What does the <p> show?

    function Dot() {
      const [position, setPosition] = useState({ x: 0, y: 0 });
    
      return (
        <>
          <p>x is {position.x}</p>
          <button onClick={() => { position.x = 5; }}>Move</button>
        </>
      );
    }
  3. Practice · Activity 3 of 7

    One handler serves both inputs, <input name="first"> and <input name="last">. Fill in the key, so that the handler changes the field named by the input that changed and keeps the other one.

    function handleChange(e) {
      setPerson({
        ...person,
        ____: e.target.value
      });
    }
    : e.target.value
  4. Practice · Activity 4 of 7

    Match each change to an array in state with the way that makes a new array.

  5. Practice · Activity 5 of 7

    The handler copies person, but writes a new artwork with only a city. You click Move. What does the <p> show (spaces at the ends left out)?

    function Card() {
      const [person, setPerson] = useState({
        name: 'Mo',
        artwork: { title: 'Red Fox', city: 'Hamburg' }
      });
    
      function handleClick() {
        setPerson({ ...person, artwork: { city: 'Berlin' } });
      }
    
      return (
        <>
          <p>{person.artwork.title} in {person.artwork.city}</p>
          <button onClick={handleClick}>Move</button>
        </>
      );
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Two lists start from the same array. The handler copies myList, changes its first item and sets myList. You click once. What do the two <p> show?

    const initialList = [
      { id: 0, title: 'Old bridge', seen: false },
      { id: 1, title: 'Town hall', seen: false }
    ];
    
    function BucketList() {
      const [myList, setMyList] = useState(initialList);
      const [yourList, setYourList] = useState(initialList);
    
      function handleClick() {
        const nextList = [...myList];
        nextList[0].seen = true;
        setMyList(nextList);
      }
    
      return (
        <>
          <p>Mine: {String(myList[0].seen)}</p>
          <p>Yours: {String(yourList[0].seen)}</p>
          <button onClick={handleClick}>I saw it</button>
        </>
      );
    }
  7. Apply · Activity 7 of 7

    Mini-task: a guest list. In main.jsx, write a GuestList component with an input and an Add button that adds the typed name at the end of a guests array (each guest an object with an id and a name) and clears the input. Every guest gets a Remove button. A Sort A–Z button sorts the guests by name. Never mutate the state array. Run it: add Zoe, Ben and Mia, sort, then remove Ben.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

A playlist: like, insert and reverse without mutation

Playlist keeps an array of song objects. Like makes a new array with map, and a new object for the liked song only. Insert puts a song at index 1 with two slices and spread. Reverse copies the array before reversing it. A run only mounts, so timers at the bottom of main.jsx click Like on Sunrise, then Insert, then Reverse, and log the first item of the initial array. In the browser the page stays live: click the buttons yourself.

main.jsx

import { useState } from 'react';

let nextId = 3;
const initialSongs = [
  { id: 0, title: 'Sunrise', likes: 0 },
  { id: 1, title: 'Night drive', likes: 2 },
  { id: 2, title: 'Rainy day', likes: 5 },
];

export default function Playlist() {
  const [songs, setSongs] = useState(initialSongs);

  function handleLike(songId) {
    setSongs(songs.map(song => {
      if (song.id === songId) {
        // A new object with one field changed
        return { ...song, likes: song.likes + 1 };
      } else {
        // No change
        return song;
      }
    }));
  }

  function handleInsert() {
    const insertAt = 1;
    setSongs([
      ...songs.slice(0, insertAt),
      { id: nextId++, title: 'Interlude', likes: 0 },
      ...songs.slice(insertAt)
    ]);
  }

  function handleReverse() {
    const nextSongs = [...songs];
    nextSongs.reverse();
    setSongs(nextSongs);
  }

  return (
    <>
      <button onClick={handleInsert}>Insert</button>
      <button onClick={handleReverse}>Reverse</button>
      <ul>
        {songs.map(song => (
          <li key={song.id}>
            {song.title} ({song.likes}){' '}
            <button onClick={() => handleLike(song.id)}>Like</button>
          </li>
        ))}
      </ul>
    </>
  );
}

// A run only mounts. Act like a user: like Sunrise, insert, then reverse.
setTimeout(() => {
  document.querySelector('li button').click();
  setTimeout(() => {
    document.querySelector('button').click();
    setTimeout(() => {
      document.querySelectorAll('button')[1].click();
      setTimeout(() => {
        console.log('initialSongs[0].likes is still', initialSongs[0].likes);
      }, 50);
    }, 50);
  }, 50);
}, 50);

Run it with

npm run dev

Output

initialSongs[0].likes is still 0
<!-- #root -->
<div id="root"><button>Insert</button><button>Reverse</button><ul><li>Rainy day (5) <button>Like</button></li><li>Night drive (2) <button>Like</button></li><li>Interlude (0) <button>Like</button></li><li>Sunrise (1) <button>Like</button></li></ul></div>
  • Sunrise shows 1 like, but initialSongs[0].likes is still 0: map made a new object for it instead of changing the old one.
  • Interlude went in at index 1, between Sunrise and Night drive, before the list was reversed.
  • Reverse changed only the copy, nextSongs; songs from the earlier render keeps its order.
  • The keys are the songs' ids, so React keeps each <li> with its song when the order changes.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 2

Fix a form with a nested address

ProfileForm edits a person with a nested address. Typing into any field changes nothing on the screen, because both handlers mutate the state object. Rewrite them. handleChange updates name or email with one copy and a computed key, [e.target.name]. handleAddressChange updates street or city: copy the address and the person. Keep initialPerson exported and unchanged.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Each input has a name that matches its key: name, email, street, city.

  2. Hint 2

    handleChange: setPerson({ ...person, [e.target.name]: e.target.value }).

  3. Hint 3

    handleAddressChange: setPerson({ ...person, address: { ...person.address, [e.target.name]: e.target.value } }).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';

export const initialPerson = {
  name: 'Ada Lovelace',
  email: 'ada@example.com',
  address: {
    street: '1 Main Street',
    city: 'London'
  }
};

export default function ProfileForm() {
  const [person, setPerson] = useState(initialPerson);

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

  function handleAddressChange(e) {
    setPerson({
      ...person,
      address: {
        ...person.address,
        [e.target.name]: e.target.value
      }
    });
  }

  return (
    <>
      <label>
        Name:
        <input name="name" value={person.name} onChange={handleChange} />
      </label>
      <label>
        Email:
        <input name="email" value={person.email} onChange={handleChange} />
      </label>
      <label>
        Street:
        <input name="street" value={person.address.street} onChange={handleAddressChange} />
      </label>
      <label>
        City:
        <input name="city" value={person.address.city} onChange={handleAddressChange} />
      </label>
      <p>
        {person.name} ({person.email}) lives at {person.address.street}, {person.address.city}.
      </p>
    </>
  );
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';

export const initialPerson = {
  name: 'Ada Lovelace',
  email: 'ada@example.com',
  address: {
    street: '1 Main Street',
    city: 'London'
  }
};

export default function ProfileForm() {
  const [person, setPerson] = useState(initialPerson);

  // TODO: typing changes nothing. Copy the objects instead of changing them.
  function handleChange(e) {
    person[e.target.name] = e.target.value;
  }

  function handleAddressChange(e) {
    person.address[e.target.name] = e.target.value;
  }

  return (
    <>
      <label>
        Name:
        <input name="name" value={person.name} onChange={handleChange} />
      </label>
      <label>
        Email:
        <input name="email" value={person.email} onChange={handleChange} />
      </label>
      <label>
        Street:
        <input name="street" value={person.address.street} onChange={handleAddressChange} />
      </label>
      <label>
        City:
        <input name="city" value={person.address.city} onChange={handleAddressChange} />
      </label>
      <p>
        {person.name} ({person.email}) lives at {person.address.street}, {person.address.city}.
      </p>
    </>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ProfileForm, {initialPerson} from './main.jsx';

const field = (name) => screen.getByRole('textbox', {name});
const summary = () => screen.getByText(/lives at/).textContent;

test('Typing a new name shows it', async () => {
  const user = userEvent.setup();
  render(<ProfileForm />);
  await user.clear(field('Name:'));
  await user.type(field('Name:'), 'Grace Hopper');
  assert.equal(field('Name:').value, 'Grace Hopper', 'after typing, the Name field holds ' + JSON.stringify(field('Name:').value));
  assert.equal(summary(), 'Grace Hopper (ada@example.com) lives at 1 Main Street, London.', 'after typing a name, the summary reads ' + JSON.stringify(summary()));
});

test('Typing a new city keeps the name and the street', async () => {
  const user = userEvent.setup();
  render(<ProfileForm />);
  await user.clear(field('City:'));
  await user.type(field('City:'), 'Paris');
  assert.equal(summary(), 'Ada Lovelace (ada@example.com) lives at 1 Main Street, Paris.', 'after typing a city, the summary reads ' + JSON.stringify(summary()));
});

test('The initial object is not changed', async () => {
  const user = userEvent.setup();
  render(<ProfileForm />);
  await user.type(field('Email:'), '.uk');
  await user.type(field('Street:'), ' West');
  assert.equal(initialPerson.email, 'ada@example.com', 'after typing, initialPerson.email is ' + JSON.stringify(initialPerson.email));
  assert.equal(initialPerson.address.street, '1 Main Street', 'after typing, initialPerson.address.street is ' + JSON.stringify(initialPerson.address.street));
});

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Exercise 2 of 2

Add, toggle and delete todos

TodoList shows three todos. Add pushes into the state array, so nothing appears; a checkbox changes its todo object, so the tick does not stay; Delete does nothing. Fix all three without mutating: Add puts a new todo with the typed title at the end and clears the field, a checkbox flips done for its own todo only, and Delete removes its own todo. Keep initialTodos exported and unchanged.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Add: setTodos([...todos, { id: nextId++, title: title, done: false }]), then setTitle('').

  2. Hint 2

    Toggle: map every todo; for the one with todoId return { ...todo, done: !todo.done }, the others unchanged.

  3. Hint 3

    Delete: setTodos(todos.filter(todo => todo.id !== todoId)).

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';

let nextId = 3;
export const initialTodos = [
  { id: 0, title: 'Buy milk', done: true },
  { id: 1, title: 'Water plants', done: false },
  { id: 2, title: 'Call Grandma', done: false }
];

export default function TodoList() {
  const [title, setTitle] = useState('');
  const [todos, setTodos] = useState(initialTodos);

  function handleAdd() {
    setTodos([...todos, { id: nextId++, title: title, done: false }]);
    setTitle('');
  }

  function handleToggle(todoId) {
    setTodos(todos.map(todo => {
      if (todo.id === todoId) {
        return { ...todo, done: !todo.done };
      } else {
        return todo;
      }
    }));
  }

  function handleDelete(todoId) {
    setTodos(todos.filter(todo => todo.id !== todoId));
  }

  return (
    <>
      <label>
        New todo:
        <input value={title} onChange={e => setTitle(e.target.value)} />
      </label>
      <button onClick={handleAdd}>Add</button>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <label>
              <input
                type="checkbox"
                checked={todo.done}
                onChange={() => handleToggle(todo.id)}
              />
              {todo.title}
            </label>
            <button onClick={() => handleDelete(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </>
  );
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';

let nextId = 3;
export const initialTodos = [
  { id: 0, title: 'Buy milk', done: true },
  { id: 1, title: 'Water plants', done: false },
  { id: 2, title: 'Call Grandma', done: false }
];

export default function TodoList() {
  const [title, setTitle] = useState('');
  const [todos, setTodos] = useState(initialTodos);

  // TODO: Add, the checkboxes and Delete do not work yet.
  function handleAdd() {
    todos.push({ id: nextId++, title: title, done: false });
  }

  function handleToggle(todoId) {
    const todo = todos.find(t => t.id === todoId);
    todo.done = !todo.done;
  }

  function handleDelete(todoId) {
  }

  return (
    <>
      <label>
        New todo:
        <input value={title} onChange={e => setTitle(e.target.value)} />
      </label>
      <button onClick={handleAdd}>Add</button>
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <label>
              <input
                type="checkbox"
                checked={todo.done}
                onChange={() => handleToggle(todo.id)}
              />
              {todo.title}
            </label>
            <button onClick={() => handleDelete(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, within} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList, {initialTodos} from './main.jsx';

const titles = () => screen.queryAllByRole('listitem').map((li) => li.textContent.replace('Delete', ''));
const box = (name) => screen.getByRole('checkbox', {name});
const deleteButton = (title) => within(screen.getByText(title).closest('li')).getByRole('button', {name: 'Delete'});

test('Add puts the new todo at the end and clears the field', async () => {
  const user = userEvent.setup();
  render(<TodoList />);
  await user.type(screen.getByRole('textbox', {name: 'New todo:'}), 'Walk the dog');
  await user.click(screen.getByRole('button', {name: 'Add'}));
  assert.deepEqual(titles(), ['Buy milk', 'Water plants', 'Call Grandma', 'Walk the dog'], 'after Add the list is ' + JSON.stringify(titles()));
  assert.equal(screen.getByRole('textbox', {name: 'New todo:'}).value, '', 'after Add the field holds ' + JSON.stringify(screen.getByRole('textbox', {name: 'New todo:'}).value));
});

test('A checkbox toggles only its own todo', async () => {
  const user = userEvent.setup();
  render(<TodoList />);
  await user.click(box('Water plants'));
  assert.equal(box('Water plants').checked, true, 'after one click Water plants is checked: ' + box('Water plants').checked);
  assert.equal(box('Call Grandma').checked, false, 'Call Grandma is checked: ' + box('Call Grandma').checked);
  await user.click(box('Buy milk'));
  assert.equal(box('Buy milk').checked, false, 'after one click Buy milk is checked: ' + box('Buy milk').checked);
});

test('Delete removes only its own todo', async () => {
  const user = userEvent.setup();
  render(<TodoList />);
  await user.click(deleteButton('Water plants'));
  assert.deepEqual(titles(), ['Buy milk', 'Call Grandma'], 'after deleting Water plants the list is ' + JSON.stringify(titles()));
});

test('The initial array and its objects are not changed', async () => {
  const user = userEvent.setup();
  render(<TodoList />);
  await user.click(box('Call Grandma'));
  await user.type(screen.getByRole('textbox', {name: 'New todo:'}), 'Read');
  await user.click(screen.getByRole('button', {name: 'Add'}));
  assert.equal(initialTodos.length, 3, 'after Add initialTodos has ' + initialTodos.length + ' items');
  assert.equal(initialTodos[2].done, false, 'after the click initialTodos[2].done is ' + initialTodos[2].done);
});

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Common mistakes

Setting an object without copying the other fields

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Ada',
    lastName: 'Lovelace'
  });

  function handleChange(e) {
    setPerson({ [e.target.name]: e.target.value });
  }

  return (
    <>
      <input name="firstName" value={person.firstName} onChange={handleChange} />
      <input name="lastName" value={person.lastName} onChange={handleChange} />
    </>
  );
}

// A run only mounts. Act like a user: type into the first field.
setTimeout(() => {
  const input = document.querySelector('input');
  const setValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
  setValue.call(input, 'Grace');
  input.dispatchEvent(new window.Event('input', { bubbles: true }));
}, 50);

What React prints

A component is changing a controlled input to be uncontrolled.

Why, and the fix

The new object has only firstName, so person.lastName becomes undefined, and the second input switches from controlled to uncontrolled; React warns. A set call replaces the whole object; it does not merge. Copy the other fields first: setPerson({ ...person, [e.target.name]: e.target.value }).

Copying an object with square brackets

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({ name: 'Ada', city: 'London' });

  function handleClick() {
    setPerson([...person, { city: 'Paris' }]);
  }

  return <button onClick={handleClick}>{person.name} in {person.city}</button>;
}

// Act like a user: click once.
setTimeout(() => document.querySelector('button').click(), 50);

What React prints

TypeError: person is not iterable

Why, and the fix

[...person] is the array spread syntax, and a plain object cannot be spread into an array, so the click throws (Chrome and Node wording; Firefox and Safari word it differently). Copy an object with curly braces and override the field: setPerson({ ...person, city: 'Paris' }).

A map callback that returns only the changed item

import { useState } from 'react';

export default function Todos() {
  const [todos, setTodos] = useState([
    { id: 0, title: 'Buy milk', done: false },
    { id: 1, title: 'Water plants', done: false }
  ]);

  function handleToggle(todoId) {
    setTodos(todos.map(todo => {
      if (todo.id === todoId) {
        return { ...todo, done: !todo.done };
      }
    }));
  }

  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id} onClick={() => handleToggle(todo.id)}>
          {todo.done ? '✓ ' : ''}{todo.title}
        </li>
      ))}
    </ul>
  );
}

// Act like a user: click the first todo once.
setTimeout(() => document.querySelector('li').click(), 50);

What React prints

TypeError: Cannot read properties of undefined (reading 'done')

Why, and the fix

map puts whatever the callback returns into the new array. For every todo except the clicked one, this callback returns nothing, so the new array holds undefined, and the render throws at todo.done (Chrome and Node wording; Firefox and Safari word it differently). Return the old object for the others: add return todo; after the if.

React in the browser: React 19.3.0 (development build) and React Testing Library, MIT, served from this site; JSX is compiled by TypeScript 6.0.3, Apache-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Treat state as read-only and replace objects

An object in state can be changed, position.x = 5, but React has no idea it changed, so nothing re-renders, and the old render's snapshot changes under you. Treat every object in state as read-only: create a new object and pass it to the set function. To keep the other fields, copy them with the object spread syntax: setPerson({ ...person, firstName: e.target.value }). One handler can serve many inputs when each input has a name: [e.target.name]: e.target.value uses that name as the key. Changing an object you have just created is fine, because nothing else references it yet.

Nested objects: copy every level

Spread is shallow: it only copies one level deep. In { ...person }, person.artwork is still the very same object, so changing the copy's artwork changes the state too. To change person.artwork.city, make a new artwork and a new person that points at it: setPerson({ ...person, artwork: { ...person.artwork, city: 'Berlin' } }). Copy all the way up from the place you change. A nested object is really a separate object that another one points to, and other objects may point to it as well. For deeply nested state, the Immer library lets you write mutating code on a draft and makes the copies; it runs only on your own computer, not in this course's runner.

Arrays: make a new array

Arrays are objects too, so treat them as read-only. Avoid push, unshift, pop, shift, splice, arr[i] = … and calling sort or reverse on the state; prefer methods that return a new array. Add with [...arr, item] or [item, ...arr]. Remove with filter. Transform or replace items with map. Insert with [...arr.slice(0, i), item, ...arr.slice(i)]. To sort or reverse, copy first, [...arr], then change the copy. The copy is shallow too: to change an object inside the array, map to a new object for that item, { ...item, seen: true }, and return the others unchanged.

Sources

Last reviewed October 5, 2026