React State Objects: Immutability Guide
Storing objects in React state is common, but it comes with one critical rule: treat state as immutable. When you mutate an object directly, React cannot detect the change and won't re-render your component, leaving the UI stale. This guide explains why immutability matters, shows the pitfalls of mutation, and teaches you the correct pattern for updating objects using the spread operator.
Key Takeaways
- React detects state changes via reference comparison; mutating an object in place keeps the same reference, so React skips re-render
- Always create a new object when updating state, using the spread (
...) operator to copy and override properties - Nested objects require copying at every level—mutate the parent, child, and all intermediate objects
- The spread operator performs a shallow copy; for deeply nested state, consider libraries like Immer
Why Is Immutability a Core Principle in React?
In JavaScript, primitive values (strings, numbers, booleans) are immutable—you cannot change the number 5 to 6; you replace a variable holding 5 with 6. Objects and arrays are mutable—you can change their properties:
const user = { name: 'Alice' };
user.name = 'Bob'; // We are mutating the original object
In React, you must treat all state—including objects and arrays—as if it were immutable.
Why is this critical?
React determines whether to re-render by comparing the current state to the previous state using a shallow equality check: prevState === currentState. It compares memory references, not contents. If you mutate an object directly, the reference to that object in memory doesn't change. Both prevState and currentState point to the exact same object in memory. React sees no difference (=== returns true) and skips the re-render, leaving your UI stale and out of sync with your actual data.
By enforcing immutability, React guarantees that reference changes signal actual data updates, enabling predictable re-renders and enabling performance optimizations like React.memo.
What Happens When You Mutate State Directly?
Let's see the problem in action. Here's a form that doesn't work:
// ANTI-PATTERN: This code fails to update the UI
import React, { useState } from 'react';
function BrokenForm() {
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: '[email protected]'
});
function handleFirstNameChange(e) {
// MUTATION! We are changing the existing person object.
// React does not detect this as a state change.
person.firstName = e.target.value;
// We are not calling setPerson, so React doesn't know to re-render.
}
return (
<>
<label>
First name:
<input
value={person.firstName}
onChange={handleFirstNameChange}
/>
</label>
<p>
{person.firstName} {person.lastName} ({person.email})
</p>
</>
);
}
export default BrokenForm;
Why This Fails:
person.firstName = e.target.value;: This line directly modifies thepersonobject in state.- No State Setter Call: We don't call
setPerson(), so React never sees an update notification. - No Re-render: Even if we called
setPerson(person), React would see that the "new" object is the same as the old (person === personistrue) and would skip the re-render. The UI never updates, and you're stuck with stale data.
How Do You Update Objects in State Correctly?
To update an object in state correctly, you must create a new object and pass it to the state setter. The JavaScript spread (...) syntax makes this simple:
// CORRECT PATTERN: Create a new object
import React, { useState } from 'react';
function WorkingForm() {
const [person, setPerson] = useState({
firstName: 'Barbara',
lastName: 'Hepworth',
email: '[email protected]'
});
function handleChange(e) {
// Create a new object:
setPerson({
// Step 1: Copy all properties from the old person object
...person,
// Step 2: Override the property that changed
[e.target.name]: e.target.value
});
}
return (
<>
<label>
First name:
<input
name="firstName"
value={person.firstName}
onChange={handleChange}
/>
</label>
<label>
Last name:
<input
name="lastName"
value={person.lastName}
onChange={handleChange}
/>
</label>
<label>
Email:
<input
name="email"
value={person.email}
onChange={handleChange}
/>
</label>
<p>
{person.firstName} {person.lastName} ({person.email})
</p>
</>
);
}
export default WorkingForm;
How It Works:
...person: The spread syntax creates a new object and copies all properties from the oldpersonobject into it.[e.target.name]: e.target.value: This computed property key uses the input's name attribute as the key and the new value as the value. This new key-value pair overwrites the one copied from...person.setPerson(...): We pass this brand new object to the state setter. React compares the new object with the old one, sees they are different references (the old object and new object are different in memory), and triggers a re-render.
Now typing into any input triggers an update and the UI reflects the changes immediately.
How Do You Update Nested Objects?
The spread syntax is "shallow"—it only copies one level deep. When updating a property on a nested object, you must create copies at every level:
import React, { useState } from 'react';
function ArtworkForm() {
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
}
});
function handleCityChange(e) {
setPerson({
...person, // Step 1: Copy top-level properties (like 'name')
artwork: { // Step 2: Replace 'artwork' with a new object
...person.artwork, // Step 3: Copy properties from the old artwork
city: e.target.value // Step 4: Override 'city' with the new value
}
});
}
return (
<>
<label>
City:
<input
value={person.artwork.city}
onChange={handleCityChange}
/>
</label>
<p>
<i>{person.artwork.title}</i> by {person.name} (located in {person.artwork.city})
</p>
</>
);
}
export default ArtworkForm;
Why All the Copying?
React's shallow equality check only looks at the first level. If you only spread person but reuse the old artwork object reference, React sees no change at the second level and may skip the re-render. By creating new objects at every level down to the changed property, you ensure React detects the update.
For deeply nested state (3+ levels), this becomes verbose. Many developers use the Immer library, which lets you write simpler "mutating" code while handling immutable updates transparently.
Best Practices for Immutable State Updates
Do:
- Always create a new object when updating state using the spread operator
- Copy at every nesting level when updating nested objects
- Use computed property names (
[key]: value) for dynamic updates in a single handler - Consider Immer for deeply nested state (3+ levels of nesting)
Avoid:
- Never mutate state directly (
state.prop = newValueis wrong) - Never call setState with the same reference (
setState(state)won't trigger a re-render) - Don't rely on side effects from mutations (the mutation might happen, but React won't know)
Frequently Asked Questions
If I use Object.assign() instead of spread, does it work?
Object.assign({}, person, { firstName: 'Bob' }) creates a new object just like the spread operator does. Both work identically for shallow copies. The spread syntax is more modern and readable, so it's preferred.
Do I need to use spread for every state update?
Yes, for objects and arrays. For primitive values (strings, numbers, booleans), you simply pass the new value: setCount(count + 1) is fine. For objects and arrays, always use spread (or Immer) to create a new reference.
What if my nested object has 5 levels of nesting?
At that point, spread becomes very verbose:
setPerson({
...person,
address: {
...person.address,
country: {
...person.address.country,
city: { ... }
}
}
})
Use Immer instead:
import { useImmer } from 'use-immer';
function Form() {
const [person, setPerson] = useImmer({...});
const handleChange = (e) => {
setPerson(draft => {
draft.address.country.city.name = e.target.value;
});
};
}
Can I mutate if I'm not rendering that state to the UI?
No. Even if you don't render the mutated value, mutating state breaks React's predictability. Components might cache derived data based on the old state, other effects might depend on state changes, or future refactors might render that data. Always treat state as immutable.
What is the performance impact of creating new objects on every update?
Modern JavaScript engines optimize object allocation, and React is designed around this pattern. The performance cost is negligible. The performance benefit from avoiding re-renders due to mutation detection far outweighs the cost of creating new objects.
Conclusion
Immutability is a cornerstone of React's reliability and performance. While creating new objects may feel like extra work, it prevents entire classes of bugs—stale UI, broken effects, missed re-renders—and enables React's optimizations.
Challenge Yourself: In the ArtworkForm example, add an input to change artwork.title. Create a new event handler handleTitleChange that correctly updates this nested property without affecting the city.
Glossary
- Immutability: The principle of not changing data directly. In React, always create new objects or arrays instead of modifying existing ones in state.
- Mutation: The act of changing an object's or array's properties or elements directly. This must be avoided for state in React.
- Shallow Copy: A copy of an object or array that copies only the top-level properties. If a property is itself an object or array, the reference is copied, not the nested object. The spread (
...) operator performs a shallow copy. - Reference Equality: React's method of detecting state changes by comparing object references (
===), not by comparing object contents.