Published at

How Not to Update the State in React

Table of Contents

If you are a beginner to React, you have almost certainly run into this scenario: you trigger a function to update your state, but when you look at your running app, the UI hasn’t changed. You add console.log statements, and you can see the data changing under the hood, but the screen stubbornly refuses to reflect it.

Let’s look at why this happens and how to fix it.

Changing State

If you are working with primitive types (like strings, numbers, and booleans), then updating the state is straightforward:

const [age, setAge] = useState(24);

function handleBirthday() {
  setAge(25);
}

When handleBirthday() is invoked, the age state variable changes from 24 to 25. React notices this change and triggers a re-render to update the UI. But what happens when our state is an array or an object?

Now consider an object in state:

const [userProfile, setUserProfile] = useState({ name: 'Alice', age: 24 });

Technically, it is possible to change the contents of the object itself. This is called a mutation:

userProfile.age = 25;

But if you tried to update the state by changing the object, you will notice that React doesn’t rerender the UI:

const [userProfile, setUserProfile] = useState({ name: 'Alice', age: 24 });

function handleBirthday() {
  userProfile.age = 25;

  setUserProfile(userProfile);
}

To understand why this happens, let’s take a quick detour into how JavaScript handles memory.

The Root of the Problem: JavaScript Data Types

In JavaScript, data types are divided into two main categories, and they behave very differently in memory:

CategoryTypesCharacteristicMemory Behavior
PrimitivesNumber, String, Boolean, null, undefined, BigInt, SymbolImmutable valuesVariables hold the actual value. Reassigning points the variable to an entirely new memory location.
ReferencesObject, Array, FunctionMutable (Changeable)Variables hold a reference (a pointer) to a location in memory.

Immutability Explained

When you appear to “change” an integer variable, you are actually performing a reassignment, not a mutation of the original value.

let x = 5;
x = 10;

In this code, a memory location is created to hold the value 5. When you execute x = 10;, a new memory location is created for the value 10, and x is updated to point there. The original value of 5 remains completely unchanged in memory. Primitive values in JavaScript are immutable. When you appear to change them, you are actually reassigning the variable to a new value.

Mutability Explained

This differs entirely from mutable types like objects and arrays:

const obj = { value: 5 };
obj.value = 10;

Here, a single object is created in memory, and the obj variable holds a reference (pointer) to it. When obj.value is updated, the content inside the existing memory location is modified. The obj variable still points to the exact same memory address.

How React Detects Changes

Calling a state setter like setAge tells React that the state might have changed. React then compares the new value with the previous state value. Internally, React uses Object.is for this comparison, which behaves similarly to === for most cases. If the two values are considered equal, React assumes nothing has changed and may skip scheduling a re-render. For objects and arrays, this effectively becomes a reference equality check, since the comparison only returns true when both values point to the same object in memory.

With the below example, when the value of age changes, React sees a new primitive value and re-renders.

const [age, setAge] = useState(24);

function handleBirthday() {
  setAge(25); // React sees a new primitive value and re-renders
}

However, if you mutate an object or an array directly and pass it back into a state setter function, React looks at the memory reference. Because the reference hasn’t changed (it’s the exact same object in memory, just with different contents), React assumes no update is required and bails out of scheduling a re-render.

In the below code, when setUserProfile() is called and passed the modified object, React sees the exact same reference and assumes there is no change since it is dealing with the same object, and so it doesn’t rerender.

const [userProfile, setUserProfile] = useState({ name: 'Alice', age: 24 });

function handleBirthday() {
  // BAD: Mutating the object directly
  userProfile.age = 25;

  // React sees the exact same memory reference and bails out of the re-render
  setUserProfile(userProfile);
}

The same is true for arrays:

const [colors, setColors] = useState(['red', 'green', 'blue']);

const changeColor = () => {
  // BAD: Mutating the array directly
  colors[0] = 'yellow';

  // React sees the exact same memory reference and bails out of the re-render
  setColors(colors);
};

In React, although objects in state are technically mutable, you should treat them as if they were immutable — like numbers, booleans, and strings. Instead of mutating them, you should always replace them.

The golden rule of React: Treat all state as if it were immutable. Never mutate state directly.

How to Update State Correctly

Instead of mutating the existing object or array, you must create a brand new copy with the desired changes, and pass that new copy to the state setter function. Because it is a new copy, it has a new memory reference, and React will process the re-render.

Updating Arrays in State

Do not use array methods that modify the original array in place (like push, pop, or splice). Instead, use non-mutating methods like map(), filter(), concat(), or the array spread syntax (...).

BAD: Mutating the array

const [names, setNames] = useState(['john', 'jane']);

// This modifies the existing array. React won't re-render!
names.push('stacy');
setNames(names);

GOOD: Creating a new array

const [names, setNames] = useState(['john', 'jane']);

// Creates a new array, copies old elements, and adds the new one
setNames([...names, 'stacy']);

Updating Objects in State

Similarly, do not modify object properties directly. Use the object spread syntax (...) to create a new object, copying existing properties and overwriting only the ones you need to change.

BAD: Mutating the object

const [fruit, setFruit] = useState({ color: 'green', name: 'banana' });

// This modifies the property in place. React won't re-render!
fruit.color = 'yellow';
setFruit(fruit);

GOOD: Creating a new object

const [fruit, setFruit] = useState({ color: 'green', name: 'banana' });

// Creates a new object, copies old properties, and overwrites 'color'
setFruit({ ...fruit, color: 'yellow' });

A Note on Nested Objects

For nested objects, the spread operator only copies the first level. The spread operator creates a shallow copy — it only duplicates the top-level keys, while nested objects still share the same memory reference. You must explicitly spread each level down to the point of the change you want to make.

For example, if you have the following:

const [user, setUser] = useState({
  name: 'Bob',
  email: 'bob@example.com',
  settings: {
    theme: 'light',
    receiveEmails: true,
  },
});

To toggle the theme to Dark Mode, you might do:

BAD: Forgetting to copy the nested object

function toggleTheme() {
  setUser((prevUser) => ({
    ...prevUser, // Copies name and email
    settings: {
      theme: 'dark', // OVERWRITES the entire settings object!
    },
  }));
}

In the above, Bob gets dark mode, but the receiveEmails property is completely wiped out. By creating a new settings object and only passing in theme, React replaces the old settings object entirely. Bob will never get another email.

GOOD: Spreading at every level

To do this correctly, you must spread the top-level object and spread the nested object before making your specific change.

function toggleTheme() {
  setUser((prevUser) => ({
    ...prevUser, // 1. Copy top-level properties (name, email)
    settings: {
      ...prevUser.settings, // 2. Copy nested properties (theme, receiveEmails)
      theme: 'dark', // 3. Overwrite only the theme
    },
  }));
}

Conclusion

By following the principle of immutability, you give React exactly what it needs to manage state effectively, resulting in snappy, predictable UI updates and a bug-free app.