Don't directly modify state in React

State in React should not be directly modified or mutated. React state must always be updated in an immutable way.
May 16, 2023React
5 min read

An essential part of every React application is state. It's important that we update state correctly in order to avoid unexpected results.

State in React components should always be immutable. This means that every state update should produce a new state. The existing state should not be modified or mutated.

Immutability is the characteristic of a data structure that cannot be modified after it is created. When an immutable data structure is created, it remains unchanged. It cannot be modified directly.

Let's take a look at two examples of directly modifying state to see the problems that it causes. The first example will consist of directly modifying an array in state, and the second example will consist of directly modifying an object in state.

Directly modifying an array in state

This example consists of a List component that displays a list of items, a Form component that receives a new item to add to the list of items, and an App component that brings the two components together.

App.tsx
import { useState } from 'react';

const List = ({ items }: { items: string[] }) => {
  return (
    <div>
      <h1>List</h1>
      <ul>
        {items.map((item) => {
          return <li key={item}>{item}</li>;
        })}
      </ul>
    </div>
  );
}

const Form = ({ handleNewItem }: { handleNewItem: (value: string) => void }) => {
  const [value, setValue] = useState('');

  const onSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
    event.preventDefault();
    handleNewItem(value);
    setValue('');
  };

  return (
    <form onSubmit={onSubmit}>
      <input value={value} onChange={(event) => setValue(event.target.value)} />
      <button>Add</button>
    </form>
  );
}

const App = () => {
  const [items, setItems] = useState(['Laptop', 'Desk']);

  const handleNewItem = (value: string) => {
    items.push(value);
    setItems(items);
  };

  return (
    <div>
      <List items={items} />
      <Form handleNewItem={handleNewItem} />
    </div>
  );
};

export default App;

The handleNewItem function is called whenever we submit the form with a new item to add to the list. However, the example doesn't work. Submitting the form does not add a new item to the list.

The problem is that state is being mutated directly rather than being updated in an immutable way. The direct mutation of state is happening in the highlighted line below.

const handleNewItem = (value: string) => {
  items.push(value);
  setItems(items);
}

We can fix the problem by updating state in an immutable way. To do so, we'll create a new array. We'll use the spread operator to copy the existing list items in the new array to ensure that the original state is not lost. Then, we'll add the new item to the array.

const handleNewItem = (value: string) => {
  const newItems = [...items, value];
  setItems(newItems);
};  

Submitting the form now adds the new item to the list of items.

When we pass a value to a state setter function like setItems, it should always be a new array or object. It should not be an existing array or object that has been directly modified.

Directly modifying an object in state

The same is true for objects in state. Directly modifying objects in state will also cause the same issue. Let's take a look at an example.

The following example consists of a User component that displays user information, a Form component that receives new user information to update the user with, and an App component that brings the two components together.

App.tsx
import { useState } from 'react';

function User({ user }: { user: { name: string } }) {
  return (
    <div>
      <h1>User</h1>
      <p>{user.name}</p>
    </div>
  );
}

function Form({
  user,
  handleNameChange,
}: {
  user: { name: string };
  handleNameChange: (value: string) => void;
}) {
  const [name, setName] = useState(user.name);

  const onSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
    event.preventDefault();
    handleNameChange(name);
    setName('');
  };

  return (
    <form onSubmit={onSubmit}>
      <input value={name} onChange={(event) => setName(event.target.value)} />
      <button>Update</button>
    </form>
  );
}

const App = () => {
  const [user, setUser] = useState({ name: 'John' });

  const handleNameChange = (name: string) => {
    user.name = name;
    setUser(user);
  };

  return (
    <div>
      <User user={user} />
      <Form user={user} handleNameChange={handleNameChange} />
    </div>
  );
};

export default App;

The handleNameChange function is called whenever we submit the form with a new name for the user. However, the example doesn't work. Submitting the form does not update the user's name.

Once again, the problem is that state is being mutated directly rather than being updated in an immutable way.

const handleChangeName = (name: string) => {  
  user.name = name;
  setUser(user);
};

We can fix the problem by updating state in an immutable way. To do so, we'll create a new object for the updated user. We'll use the spread operator to copy the existing user in the new object to ensure that the original state is not lost. Then, we'll overwrite the user's name with the newly updated name.

const handleChangeName = (name: string) => {  
  const updatedUser = { ...user, name };
  setUser(updatedUser);
};

Submitting the form now updates the user's name with the new name that was entered.

Conclusion

By not mutating state directly, we can avoid creating unexpected bugs and performance bottlenecks in our React applications.

Updating state in an immutable way:

  • Makes the behaviors of our application more predictable.
  • Makes React change detection more efficient.
  • Makes testing and debugging components easier.
  • Makes components more reusable by reducing hidden side-effects.

New
Be React Ready

Learn modern React with TypeScript.

Learn modern React development with TypeScript from my books React Ready and React Router Ready.