Skip to content
React rc state-events 5 min read

Updating Objects in State

State doesn’t have to be a single number or string—it’s common to hold an object that groups related fields together, like a form or a user profile. The catch is that React expects you to treat that object as read-only. Instead of editing it in place, you create a brand-new object with the changes applied. Understanding why, and the spread syntax that makes it painless, is essential to writing correct React.

Why you must not mutate state

Technically, a JavaScript object stored in state is mutable—nothing stops you from writing user.name = "Sam". But React won’t notice. React decides whether to re-render by comparing the previous state value to the next one by reference (Object.is). If you mutate the same object, the reference is identical, so React assumes nothing changed and skips the re-render.

import { useState } from "react";

function Profile() {
  const [user, setUser] = useState({ name: "Ada", age: 36 });

  function handleBirthday() {
    user.age = user.age + 1; // ❌ mutates the existing object
    setUser(user);           // same reference → React sees no change
  }

  return <button onClick={handleBirthday}>Age: {user.age}</button>;
}

Output:

Clicking does nothing visible — the label stays "Age: 36"

Mutation also breaks features that rely on snapshots of past state (like time-travel debugging) and can introduce bugs where a stale value leaks into the next render. The fix is to always produce a new object.

Updating with the spread operator

The cleanest way to create a new object based on an old one is the spread operator (...). Spread copies every property of the existing object into a fresh one, then you override the fields you want to change. Because the result is a new reference, React re-renders.

function Profile() {
  const [user, setUser] = useState({ name: "Ada", age: 36 });

  function handleBirthday() {
    setUser({ ...user, age: user.age + 1 }); // ✅ new object
  }

  return <button onClick={handleBirthday}>Age: {user.age}</button>;
}

The key part is { ...user, age: user.age + 1 }: copy all of user, then replace age. Order matters—properties written after the spread win. This pattern shines in forms, where a single handler can update whichever field changed using a computed key:

function ContactForm() {
  const [form, setForm] = useState({ first: "", last: "", email: "" });

  function handleChange(e) {
    const { name, value } = e.target;
    setForm({ ...form, [name]: value }); // dynamic key from input's name
  }

  return (
    <form>
      <input name="first" value={form.first} onChange={handleChange} />
      <input name="last" value={form.last} onChange={handleChange} />
      <input name="email" value={form.email} onChange={handleChange} />
    </form>
  );
}

One handler now drives three inputs, because [name] resolves to whichever field’s name attribute fired the event.

Replacing vs merging

Spread gives you a merge: existing fields are kept and only the listed ones change. If you instead pass a plain object without spreading, you replace the whole thing—any field you forget is gone.

// Merge: keeps name and email, updates age
setForm({ ...form, age: 40 });

// Replace: name and email are now undefined!
setForm({ age: 40 });

A common bug: forgetting the spread inside a per-field handler. Without ...form, typing in one input wipes every other field. Spread first, override second.

Note also that the spread operator is a shallow copy. It duplicates only the top level; nested objects are shared by reference between the old and new state.

Updating nested objects

Because the copy is shallow, you must spread at every level you intend to change. Consider a profile with a nested address:

function Settings() {
  const [user, setUser] = useState({
    name: "Ada",
    address: { city: "London", zip: "EC1" },
  });

  function moveCity() {
    setUser({
      ...user,                       // copy top level
      address: {
        ...user.address,             // copy nested object
        city: "Paris",               // override one field
      },
    });
  }

  return <button onClick={moveCity}>City: {user.address.city}</button>;
}

If you only spread the outer object and assign address directly, you’d replace the entire nested object and lose zip. Each layer you reach into needs its own spread.

ApproachResultWhen to use
{ ...obj, field: x }Merge top levelFlat objects, single-field updates
{ ...obj, nested: { ...obj.nested, f: x } }Merge nested levelShallow nesting (1–2 levels)
Immer’s produce / useImmer”Mutate” a draft, get an immutable resultDeep or complex nesting

When nesting gets painful: Immer

Spreading by hand stays readable for one or two levels, but three or four levels deep it becomes a wall of ... that’s easy to get wrong. The community answer is Immer, a tiny library that lets you write code that looks like mutation while producing a correctly copied, immutable result under the hood.

import { useImmer } from "use-immer";

function DeepSettings() {
  const [state, updateState] = useImmer({
    user: { profile: { address: { city: "London" } } },
  });

  function moveCity() {
    updateState((draft) => {
      draft.user.profile.address.city = "Paris"; // safe, Immer copies for you
    });
  }

  return <button onClick={moveCity}>{state.user.profile.address.city}</button>;
}

The draft is a special proxy: you mutate it freely, and Immer figures out exactly which objects to clone. Reach for it when manual spreads start hurting—but for shallow state, plain spread is perfectly fine and dependency-free.

Best Practices

  • Treat every object in state as immutable—never assign to its properties directly.
  • Always create a new object (usually via spread) so React detects the change by reference.
  • Spread first, then override, so your explicit fields win and nothing is dropped.
  • Spread at every nesting level you modify; remember spread is a shallow copy.
  • Use a computed key ([name]: value) to handle many form fields with one handler.
  • Reach for Immer (useImmer) once nesting is more than two levels deep.
  • Keep state shallow when you can—flat shapes are far easier to update correctly.
Last updated June 14, 2026
Was this helpful?