Skip to content

React Tutorial State and Events

Updating Objects in State Without Mutation

Lesson 54 of 156 2 min read By Deepak Paswan Updated Oct 10, 2026

Runnable lesson · press Try it on the examples to edit and run them here.

State that is an object must be treated as read-only. To change it, create a new object with the changes and pass it to the setter.

Why not just change a property?

App.jsx
JSX
function App() {
  const [user, setUser] = React.useState({ name: "Ada", city: "London" });

  function wrongRename() {
    user.name = "Grace"; // mutation: React cannot notice this
    setUser(user);       // same object, so React skips the re-render
  }

  return (
    <div>
      <p>{user.name} in {user.city}</p>
      <button onClick={wrongRename}>Rename (wrong way)</button>
    </div>
  );
}

The name in the object changes, but the screen does not: setUser was given the same object, and React compares by identity. There are other bugs too: mutated objects break memoization and make updates unpredictable. The fix is always to create a copy.

The spread syntax

{ ...user } copies all properties into a new object. Properties written after it override:

App.jsx
JSX
function App() {
  const [user, setUser] = React.useState({ name: "Ada", city: "London" });

  function rename() {
    setUser({ ...user, name: "Grace" });
  }

  return (
    <div>
      <p>{user.name} in {user.city}</p>
      <button onClick={rename}>Rename</button>
    </div>
  );
}

See Object Spread if the syntax is new.

One handler for many fields

A computed property name lets one handler update any field:

App.jsx
JSX
function App() {
  const [form, setForm] = React.useState({ name: "Ada", city: "London" });

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

  return (
    <div>
      <label>Name: <input aria-label="name" name="name" value={form.name} onChange={handleChange} /></label>
      <label>City: <input aria-label="city" name="city" value={form.city} onChange={handleChange} /></label>
      <p>{form.name} lives in {form.city}</p>
    </div>
  );
}

Nested objects

The spread copy is shallow: nested objects are still shared. To change something deep, copy every level on the path:

App.jsx
JSX
function App() {
  const [user, setUser] = React.useState({
    name: "Ada",
    address: { city: "London", zip: "N1" },
  });

  function move() {
    setUser({
      ...user,                                   // copy the user
      address: { ...user.address, city: "Paris" } // copy address and change the city
    });
  }

  return (
    <div>
      <p>{user.name}, {user.address.city}</p>
      <button onClick={move}>Move to Paris</button>
    </div>
  );
}

The old user and its address stay untouched; the new ones share the unchanged parts.

When the nesting gets deep

Deeply nested state is hard to update. Two good options:

  1. Flatten the state. Store { name, city, zip } or keep related data in separate states and use ids to link them.
  2. Use a helper library such as Immer, which lets you write “mutating” code on a draft while producing a new immutable object (the use-immer package gives you useImmer). It is optional; the plain spread approach is enough for most apps.

Updating based on previous state

If your update depends on the previous object, use the updater function so you always start from the latest value:

App.jsx
JSX
function App() {
  const [game, setGame] = React.useState({ player: "Ada", score: 0 });

  return (
    <div>
      <p>Score: {game.score}</p>
      <button onClick={() => setGame((g) => ({ ...g, score: g.score + 1 }))}>Add point</button>
    </div>
  );
}

Remember the parentheses around the object literal in an arrow function: (g) => ({ ... }). Without them the braces are read as a function body.

Key points

  • Never assign to a property of state; always set a new object.
  • Copy with { ...old, changed } and copy each nested level you change.
  • Flatten deep state or use Immer when updates get awkward.