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:
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'The Correct Pattern: Create a New Object
Always create a brand-new object when updating object state. The spread operator (...) makes this concise:
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' }))A Complete Form Example
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>
)
}Nested Object Updates
Nested objects require a spread at every level you are changing. A single top-level spread only creates a shallow copy:
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:
// 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:
npm install immer use-immer
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>
)
}Correct vs Incorrect: Side-by-Side
// 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]: valuefor a single handler covering multiple fieldsImmer 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