Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 20

Mutable vs. Immutable State Updates

Recommended resources

Hello! Welcome to our next lesson.

In our last session, we established the core rules for when a React component re-renders: a state change within the component, or a re-render of its parent. This brings up a critical question: when you call setMyState(newValue), how does React actually know if newValue is different from the old state?

For simple values like numbers or strings, the answer is straightforward (5 is different from 4). But what about complex values like objects and arrays? This is where a fundamental concept in both JavaScript and React comes into play: immutability.

Today, we will dive deep into the difference between mutable and immutable state updates. Understanding this distinction is one of the most important steps to mastering useState and preventing a common class of bugs. By the end of this lesson, you'll know why directly changing an object or array in state fails silently and how to update them correctly to ensure your UI always reflects the latest data.


1. Mutability vs. Immutability in JavaScript

Before we see how this applies to React, we need to solidify the underlying JavaScript concepts. In JavaScript, data types behave differently depending on whether they are primitive or reference types.

  • Primitive Types: string, number, boolean, null, undefined, symbol, bigint. These values are inherently immutable. You can't change a primitive value; you can only replace it with a new one.
  • Reference Types: object, array, function. These values are mutable, meaning you can change their internal contents (properties or elements) without creating a new value.

This distinction in how they are stored and accessed in memory is the root of the concept. The following video provides an excellent explanation.

Mutability vs Immutability in JavaScript

Watch this video from Cloudaffle to understand the core difference between primitive and reference types, and what it means for a value to be mutable or immutable in JavaScript.

Watch from the beginning to 06:34. Pay close attention to: The difference between how primitive and reference types are stored in memory. The example showing that changing a variable assigned from another primitive variable does not affect the original. The example showing that changing a property of an object does affect all variables referencing that object.

The key idea is that variables holding reference types store a pointer to a location in memory. If you copy that variable, you're just copying the pointer, not the object itself. Modifying the object through one pointer will be visible through all other pointers.



2. Why Mutation Fails in React

Now, let's connect this back to React. In our last lesson, we learned that calling a state setter function triggers a re-render. More precisely, React queues a re-render and, to optimize performance, it first compares the new state with the old state. If it thinks they are the same, it will skip the re-render.

The comparison React uses is a shallow equality check, like the === operator.

  • For primitive types, oldValue === newValue works as you'd expect.
  • For reference types (objects and arrays), oldState === newState checks if both variables point to the exact same object in memory. It does not check if the properties inside the object have changed.

This is why mutating state causes bugs. If you directly change a property of a state object and then pass that same object to the setter, React sees that the memory address hasn't changed and concludes that nothing happened.

Let's see this common mistake in action.

React Render Tutorial - 5 - State Immutability

The following video by Codevolution demonstrates exactly what happens when you try to mutate an object and an array held in state.

Watch the following two segments: Object Mutation (00:30 - 04:06): Observe how changing person.fname and person.lname directly and then calling setPerson(person) fails to trigger a re-render. Array Mutation (04:54 - 07:39): See how using .push() on a state array and calling the setter function also fails to update the UI.

As you saw, even though the data inside the object and array changed, the UI didn't update because, from React's perspective, we gave it back the same object it already had.

React State Immutability Illustration
A simple visual metaphor: React's rule is to not mutate state directly. Instead, you must provide a new value to trigger an update.


3. The Immutable Pattern: Updating State Correctly

The golden rule is: Treat state as read-only.

Instead of changing an existing object or array, you must always create a new one and pass it to your state setter function. This provides a new reference, allowing React's === check to detect the change and trigger a re-render.

Updating Objects

To update an object, you create a new object. A convenient way to do this without manually copying every property is the ES6 spread syntax (...).

// Suppose we have this state:
const [person, setPerson] = useState({ 
  firstName: 'Barbara',
  lastName: 'Hepworth',
  email: 'bhepworth@sculpture.com'
});

// To update the first name, we do this:
function handleFirstNameChange(e) {
  setPerson({
    ...person, // 1. Copy all properties from the old person object
    firstName: e.target.value // 2. Override the one property that changed
  });
}

This setPerson({...}) call creates a brand new object. It passes React's change detection, and the component re-renders.

Correct Way to Update Objects in React State
This code snippet shows the correct immutable pattern for updating an object in state. The spread operator (`...`) is used to create a new object copy, which is then modified and passed to the state setter function.

For a more in-depth explanation and interactive examples, the official React documentation is an excellent resource.

Updating Objects in State

The React team has written a fantastic guide on this topic. Let's read the key sections on updating objects.

Read the sections titled 'What’s a mutation?', 'Treat state as read-only', and 'Copying objects with the spread syntax'. These sections reinforce the core principles and provide working examples.

Updating Arrays

The same principle applies to arrays. You must avoid methods that mutate the original array (like push, pop, splice) and instead use methods that return a new array.

Here is a quick reference:

Operation Avoid (mutates) Prefer (returns new array)
Adding push, unshift concat, [...arr, newItem] (spread syntax)
Removing pop, shift, splice filter
Replacing arr[i] = ..., splice map
Sorting sort, reverse First create a copy: [...arr].sort()

Examples:

// ADDING an item
setItems([...items, newItem]);

// REMOVING an item with a specific id
setItems(items.filter(item => item.id !== idToRemove));

// REPLACING an item
setItems(items.map(item => {
  if (item.id === idToReplace) {
    return { ...item, property: 'new value' }; // Return a new, updated item
  }
  return item; // Return the original, unchanged item
}));

The React documentation also provides a comprehensive guide for arrays.

Updating Arrays in State

Now, let's look at the official guide for arrays, which covers the common operations you'll need.

Read the sections 'Updating arrays without mutation', 'Adding to an array', 'Removing from an array', and 'Replacing items in an array'. Focus on the table that contrasts mutating vs. non-mutating methods.



4. Handling Nested State

A crucial detail is that the spread syntax and array methods like map() and filter() perform a shallow copy. They only copy one level deep. The nested objects or arrays inside are still the same references.

If you need to update a nested property, you must create new copies at every level of the object from the top down to the change.

const [person, setPerson] = useState({
  name: 'Niki de Saint Phalle',
  artwork: {
    title: 'Blue Nana',
    city: 'Hamburg',
  }
});

function handleCityChange(e) {
  setPerson({
    ...person, // 1. Copy the top-level person object
    artwork: { // 2. Replace the artwork property with a new object
      ...person.artwork, // 3. Copy the properties from the old artwork object
      city: e.target.value // 4. Override the nested city property
    }
  });
}

This can feel a bit verbose, but it's essential for correctness. The React docs you just read cover this in their "Updating a nested object" and "Updating objects inside arrays" sections (feel free to revisit LINK, section 5 and LINK, section 7 if you need a refresher).



Conclusion

You've just learned one of the most fundamental concepts for working with state in React. Mastering immutable updates will save you from countless hours of debugging.

Key Takeaways:

  • JavaScript's reference types (objects, arrays) are mutable, while primitive types are immutable.
  • React relies on a shallow === reference check to detect state changes.
  • Mutating a state object or array will not trigger a re-render because the memory reference does not change.
  • Always create a new object or array for updates. Use the spread syntax (...) for objects and non-mutating methods like map() and filter() for arrays.
  • For nested data, you must create copies at every level of the structure you are updating.

Next Up:

We've now completed our deep dive into the useState hook, covering how to initialize, update, and understand its rendering behavior. The next logical step is to connect these state updates to user interactions. In our next lesson, we will learn how to attach event handlers like onClick and onChange to JSX elements, allowing us to build truly interactive components.