ReactUpdating Objects in State

Updating Objects in State

Storing an object in state is a common pattern — a form, a user record, a configuration — but objects require careful handling. React detects state changes by comparing references, not by doing a deep comparison of every field. This means you must always replace the object, never mutate it in place.

Why Mutation Is Invisible to React

When you write obj.name = 'Alice' you are changing a field on the same object reference. React checks whether the state reference changed (prev === next). Since the reference is the same, React sees no change and skips the re-render entirely:

JSX
const [user, setUser] = useState({ name: 'Bob', age: 30 })

// ✗ MUTATION — same object reference; React sees no change; no re-render
user.name = 'Alice'
setUser(user)   // React compares prev === user → true → skips re-render!

// Console.log will show 'Alice' but the UI still shows 'Bob'
Warning
This is one of the most common React bugs. The code looks like it should work because `setUser` is being called, but the reference equality check short-circuits the update.
The Correct Pattern: Create a New Object

Always create a brand-new object when updating object state. The spread operator (...) makes this concise:

JSX
const [user, setUser] = useState({ name: 'Bob', age: 30, role: 'dev' })

// ✓ CORRECT — spread creates a new object with all existing fields,
// then we override just the fields we want to change
setUser({ ...user, name: 'Alice' })
// Result: { name: 'Alice', age: 30, role: 'dev' }

// Functional update form (safer when building on current state):
setUser(prev => ({ ...prev, name: 'Alice' }))

// Updating multiple fields at once:
setUser(prev => ({ ...prev, name: 'Alice', role: 'admin' }))
Note
The spread goes first so that later fields override earlier ones. Writing `{ name: 'Alice', ...prev }` would put the spread AFTER the new value, so `prev.name` would overwrite `'Alice'`. Always put the spread first.
A Complete Form Example

JSX
import { useState } from 'react'

function ProfileForm() {
  const [profile, setProfile] = useState({
    firstName: '',
    lastName: '',
    email: '',
    bio: '',
  })

  // Single handler for all fields using the input's name attribute
  function handleChange(e) {
    const { name, value } = e.target
    setProfile(prev => ({ ...prev, [name]: value }))
  }

  function handleSubmit(e) {
    e.preventDefault()
    console.log('Saving profile:', profile)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        name="firstName"
        value={profile.firstName}
        onChange={handleChange}
        placeholder="First name"
      />
      <input
        name="lastName"
        value={profile.lastName}
        onChange={handleChange}
        placeholder="Last name"
      />
      <input
        name="email"
        type="email"
        value={profile.email}
        onChange={handleChange}
        placeholder="Email"
      />
      <textarea
        name="bio"
        value={profile.bio}
        onChange={handleChange}
        placeholder="Short bio"
      />
      <button type="submit">Save Profile</button>
    </form>
  )
}
Tip
The computed property key `[name]: value` is a powerful pattern for a single `handleChange` function that serves all inputs. Each input must have a `name` attribute that matches the corresponding object key.
Nested Object Updates

Nested objects require a spread at every level you are changing. A single top-level spread only creates a shallow copy:

JSX
const [config, setConfig] = useState({
  theme: {
    color: 'blue',
    fontSize: 14,
  },
  notifications: {
    email: true,
    push: false,
  },
})

// ✗ WRONG — only shallow copy; 'theme' is still the original nested reference
setConfig(prev => ({ ...prev, theme: { color: 'red' } }))
// Result: theme is now { color: 'red' } — fontSize is LOST!

// ✓ CORRECT — spread at every level you are changing
setConfig(prev => ({
  ...prev,
  theme: {
    ...prev.theme,   // keep existing theme fields
    color: 'red',    // override only color
  },
}))
// Result: theme is { color: 'red', fontSize: 14 } ✓
Deeply Nested Updates — The Problem

The deeper the nesting, the more verbose and error-prone the spread syntax becomes. Consider a three-level deep update:

JSX
// Three levels deep — gets awkward fast
setUser(prev => ({
  ...prev,
  address: {
    ...prev.address,
    city: {
      ...prev.address.city,
      zip: '10001',
    },
  },
}))
Immer: Mutate-Style Updates Without the Pain

The Immer library lets you write code that looks like mutation but produces a correctly immutable update under the hood. It is especially useful for deeply nested objects:

Bash
npm install immer use-immer

JSX
import { useImmer } from 'use-immer'

function ProfileEditor() {
  const [user, updateUser] = useImmer({
    name: 'Alice',
    address: {
      city: 'Toronto',
      country: {
        code: 'CA',
        name: 'Canada',
      },
    },
  })

  function handleCityChange(newCity) {
    // Looks like mutation, but Immer creates a new object safely:
    updateUser(draft => {
      draft.address.city = newCity
    })
  }

  function handleCountryCode(code) {
    updateUser(draft => {
      draft.address.country.code = code
    })
  }

  return (
    <div>
      <input
        value={user.address.city}
        onChange={(e) => handleCityChange(e.target.value)}
      />
    </div>
  )
}
Note
Immer works by creating a "draft" proxy of your state. You mutate the draft freely; Immer intercepts every mutation and produces a new immutable object at the end. The original state is never touched.
Correct vs Incorrect: Side-by-Side

JSX
// Given:
const [product, setProduct] = useState({
  name: 'Widget',
  price: 9.99,
  stock: 100,
})

// ✗ WRONG — mutating in place
product.price = 14.99
setProduct(product)  // same reference, no re-render

// ✗ WRONG — shallow spread loses fields not mentioned
setProduct({ price: 14.99 })  // name and stock are gone

// ✓ CORRECT — full spread + override
setProduct(prev => ({ ...prev, price: 14.99 }))
  • Never mutate the state object directly — React uses reference equality to detect changes

  • Spread at every level you are modifying: { ...prev, nested: { ...prev.nested, field: value } }

  • Use computed keys [e.target.name]: value for a single handler covering multiple fields

  • Immer is worth adding for complex nested state — it dramatically reduces boilerplate

  • Functional updates (prev =>) are safer than reading the variable directly when multiple updates may be queued