Updating Objects in State

A huge amount of real-world React state is not a single number or string — it’s an object: a user profile, a form’s field values, an item in a cart. Objects in JavaScript are reference types, and React decides whether to re-render by comparing the old state reference to the new one. That single fact is the key to everything in this lesson: you must never mutate an object already sitting in state. Instead, you always create a brand-new object and hand that to your state setter. Get this wrong and your UI will silently stop updating, even though the data “changed.”

Overview / How it works

When you call useState({ ... }), React stores a reference to that object internally. On every render, useState returns that same reference back to you — it does not clone it. If you reach into the object and change a property directly (user.name = "Ana"), you have mutated the exact object React is holding onto. Nothing tells React to re-render, because as far as React’s Object.is comparison is concerned, the reference did not change — it’s literally the same object in memory.

React schedules a re-render only when you call the state setter function (e.g. setUser) with a new value. On update, React compares the new value to the previous one using Object.is. For primitives (numbers, strings, booleans) this comparison is by value, but for objects and arrays it is a reference comparison. So the rule becomes mechanical: to update object state, build a new object (usually by spreading the old one and overriding the fields that changed) and pass that new object to the setter. This is called treating state as immutable — you never edit state in place, you always replace it.

This matters even more once you consider nested objects. Spreading an object with { ...obj } only performs a shallow copy — it copies the top-level properties, but any nested object or array inside it is still the *same reference* as before. If you spread the outer object but then mutate a nested property directly, you’ve made the same mistake one level deeper. The fix is to spread at every level you’re changing, from the outermost object down to the property you’re actually modifying.

Once you internalize “never mutate, always replace,” updating object state becomes a small, repeatable pattern you’ll use constantly: forms, settings panels, cart items, nested API responses — all of it follows the same shape.

Syntax

const [state, setState] = useState({ field1: value1, field2: value2 });

// Update one field, keep the rest
setState({ ...state, field1: newValue });

// Update using the previous state safely (functional update)
setState(prevState => ({ ...prevState, field1: newValue }));
  • useState({...}) — initializes state with an object; state is the current object, setState replaces it.
  • { ...state, field1: newValue } — spreads all existing properties into a new object literal, then overwrites just the one(s) listed after the spread. Order matters: the override must come after the spread.
  • Functional update formsetState(prev => ({ ...prev, ... })) — reads the guaranteed-latest state instead of a possibly-stale closed-over variable; use this when the new state depends on the previous state, especially inside effects, timeouts, or handlers that might run more than once before a re-render.
  • Nested objects — you must spread at each level: { ...state, address: { ...state.address, city: newCity } }.

Examples

Example 1: Updating a single field in a flat object

import { useState } from "react";

function ProfileForm() {
  const [user, setUser] = useState({ name: "Ana", email: "ana@example.com" });

  function handleNameChange(e) {
    setUser({ ...user, name: e.target.value });
  }

  return (
    <div>
      <input value={user.name} onChange={handleNameChange} />
      <p>Name: {user.name}</p>
      <p>Email: {user.email}</p>
    </div>
  );
}

export default ProfileForm;

Renders: a text input bound to user.name, followed by two paragraphs showing the current name and email.

Every keystroke calls handleNameChange, which builds a brand-new object with ...user copying email over unchanged and name overwritten with the input’s new value. Because setUser receives a new object reference, React re-renders and the paragraph updates. email is preserved automatically since the spread ran before the override.

Example 2: Updating a nested object

import { useState } from "react";

function AddressForm() {
  const [person, setPerson] = useState({
    name: "Marco",
    address: { city: "Lisbon", zip: "1000-001" }
  });

  function handleCityChange(e) {
    setPerson({
      ...person,
      address: { ...person.address, city: e.target.value }
    });
  }

  return (
    <div>
      <input value={person.address.city} onChange={handleCityChange} />
      <p>{person.name} lives in {person.address.city} ({person.address.zip})</p>
    </div>
  );
}

export default AddressForm;

Renders: an input bound to the nested city value, and a sentence combining name, city, and zip.

Notice the two levels of spreading: ...person copies name unchanged, and a fresh address object is built with ...person.address copying zip unchanged while city is overwritten. If you only spread person and wrote person.address.city = e.target.value directly, you’d mutate the shared address object and React would not detect the change reliably.

Example 3: A reusable field updater for multi-field forms

import { useState } from "react";

function SignupForm() {
  const [form, setForm] = useState({ username: "", password: "", age: 0 });

  function handleChange(e) {
    const { name, value } = e.target;
    setForm(prevForm => ({
      ...prevForm,
      [name]: name === "age" ? Number(value) : value
    }));
  }

  function handleSubmit(e) {
    e.preventDefault();
    console.log("Submitting:", form);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="username" value={form.username} onChange={handleChange} />
      <input name="password" type="password" value={form.password} onChange={handleChange} />
      <input name="age" type="number" value={form.age} onChange={handleChange} />
      <button type="submit">Sign up</button>
    </form>
  );
}

export default SignupForm;

Renders: a form with username, password, and age inputs, plus a submit button.

Output (after typing “cleo”, “secret1”, “27” and clicking Submit):

Submitting: {username: 'cleo', password: 'secret1', age: 27}

A single handleChange handles all three fields by using the input’s name attribute as a computed property key ([name]: value), so only that one field is overwritten while the rest spread through unchanged. It uses the functional update form (prevForm => (...)) since each keystroke update logically depends on the immediately prior state, which is the safest pattern for handlers that fire rapidly.

How it works step by step

  • Initial render: useState({...}) creates the object once and stores a reference to it inside React’s internal fiber for this component.
  • User interaction: an event handler runs and calls the setter with a newly constructed object (via spread).
  • Update scheduled: React compares the new object reference to the old one with Object.is. Since spreading always produces a new object, the references differ and a re-render is scheduled.
  • Render phase: your component function runs again; useState now returns the new object, so JSX reading user.name or person.address.city reflects the updated value.
  • Commit phase: React applies the minimal DOM changes (reconciliation) needed to reflect the new JSX output — it does not need to know which specific property changed inside your object, only that the state value itself is new.
  • Unrelated fields untouched: because the spread copies every other property by reference (or by value for primitives), fields you didn’t touch keep their previous values without any extra code.

Common Mistakes

Mistake 1: Mutating the object directly

const [user, setUser] = useState({ name: "Ana", email: "ana@example.com" });

function handleNameChange(e) {
  user.name = e.target.value; // mutates state in place
  setUser(user); // same reference — React sees no change
}

This is wrong because user.name = ... mutates the exact object React already holds, and then setUser(user) passes back that same reference. Object.is(oldUser, newUser) is true, so React may skip the re-render entirely, or a later unrelated update could suddenly “reveal” the mutated value out of sync with the render — either way the behavior is unreliable.

Corrected:

function handleNameChange(e) {
  setUser({ ...user, name: e.target.value });
}

Mistake 2: Only spreading the top level of a nested object

function handleCityChange(e) {
  const updated = { ...person };
  updated.address.city = e.target.value; // still mutates the shared nested object
  setPerson(updated);
}

{ ...person } only copies the top level — updated.address is still the exact same nested object as person.address. Writing to updated.address.city mutates that shared object, which can cause the same reliability problems as Mistake 1, and can also corrupt state if address is referenced elsewhere.

Corrected:

function handleCityChange(e) {
  setPerson({ ...person, address: { ...person.address, city: e.target.value } });
}

Mistake 3: Overriding before spreading

setUser({ name: e.target.value, ...user }); // spread runs AFTER the override, wiping it out

Object spread applies in order — properties listed later win. Here ...user comes after name, so the old user.name silently overwrites the new value. Always put the spread first, then your overrides.

Best Practices

  • Never write state.property = value for state that came from useState or useReducer — always call the setter with a new object.
  • Spread at every level you’re modifying: one level for a flat object, two levels for a one-level-nested object, and so on.
  • Use the functional update form (setState(prev => ({...prev, ...}))) whenever the new state depends on the previous state, especially in effects, timers, or rapid-fire handlers.
  • For deeply nested state (three or more levels), consider flattening your state shape or splitting it into multiple useState calls — deeply nested spreads become hard to read and easy to get wrong.
  • Use computed property names ([e.target.name]: value) to write one generic change handler for multi-field forms instead of one handler per field.
  • Treat arrays inside object state the same way: never push/splice in place — build a new array with map, filter, or spread, then include it in the new object.

Practice Exercises

  • Create a Settings component with state { theme: "light", notifications: true }. Add a button that toggles notifications without touching theme, using the immutable spread pattern.
  • Build a ProductForm with nested state { name: "", pricing: { amount: 0, currency: "USD" } }. Add an input that updates only pricing.amount while leaving currency and name unchanged.
  • Take the flawed snippet from “Mistake 2” above, run through what would happen if two different components both read from the same original person object elsewhere in the app, and rewrite it using proper nested spreading.

Summary

  • React detects state changes by reference for objects — mutating an object in place does not reliably trigger a re-render.
  • Always create a new object with { ...oldState, changedField: newValue } rather than editing state directly.
  • Spread copies only one level deep; nested objects need their own spread at each level you’re changing.
  • Use the functional updater form when new state depends on previous state.
  • Computed property names let one handler manage many form fields cleanly.
  • Deeply nested state is a sign to flatten your state shape or split it into separate useState calls.