TypeOfNaN

How to update nested state in React

Nick Scialli
June 10, 2021

In React, we can update state using state setter functions. In function components, a state setter is provided by the useState hook. Incrementing a count using a button would look like this:

import { useState } from 'react';

function MyComponent() {
  const [count, setCount] = useState(0);
  return (
    <button
      onClick={() => {
        setCount(count + 1);
      }}
    >
      Increment
    </button>
  );
}

Similarly, incrementing a count in a class component would look like this:

class MyComponent extends React.Component {
  constructor() {
    this.state = { count: 0 };
  }

  render() {
    return (
      <button
        onClick={() => {
          this.setState({ count: this.state.count + 1 });
        }}
      >
        Increment
      </button>
    );
  }
}

(Note: moving forward, I’ll just focus on function components, but everything we discuss can easily be translated to class components.)

Pretty uncontroversial up to this point! But what if we have nested state? That is, we need to update a property in our state object one (or more) levels deep?

Let’s learn some conventions for how to do this in different scenarios.

Updating a property one level deep

In a new example, we have a stateful user that has an email property and password property (think signup form). Our component might look like this:

function SignupForm() {
  const [user, setUser] = useState({ email: '', password: '' });
  return (
    <form>
      <label htmlFor="email">Email address</label>
      <input id="email" name="email" value={user.email} />
      <label htmlFor="password">Password</label>
      <input id="password" name="password" value={user.password} />
    </form>
  );
}

Great—but how do we update our state when the user types in an input? You might be tempted to do something like the following, but it’s wrong!

Don’t do this:

<input
  id="email"
  name="email"
  value={user.email}
  onChange={(e) => {
    user.email = e.target.value;
    setUser(user);
  }}
/>

We just mutated our user state object, which is a big no-no in React. The React diffing algorithm sees this as the same object as our previous state (it is the same object) and therefore may not update the DOM for us.

So what should we do?

Shallow copy!

The pattern you usually see in React is shallow copying an object. We can do this using the spread operator like so:

<input
  id="email"
  name="email"
  value={user.email}
  onChange={(e) => {
    setUser({
      ...user,
      email: e.target.value,
    });
  }}
/>

We’re setting user to a newly-created object in memory to make React happy, copying the previous user object using the spread operator, and then overwriting the email property with our own value. This can be repeated for our password field:

<input
  id="password"
  name="password"
  value={user.password}
  onChange={(e) => {
    setUser({
      ...user,
      password: e.target.value,
    });
  }}
/>

This feels redundant

If this feels redundant, it’s because it is! One option we have is to create a change handler that handles any property of user. Note that the following example depends on the name attribute of our inputs to map perfectly to our user state object.

function SignupForm() {
  const [user, setUser] = useState({ email: '', password: '' });

  function onUserChange(e) {
    setUser({
      ...user,
      [e.target.name]: e.target.value,
    });
  }

  return (
    <form>
      <label htmlFor="email">Email address</label>
      <input
        id="email"
        name="email"
        value={user.email}
        onChange={onUserChange}
      />
      <label htmlFor="password">Password</label>
      <input
        id="password"
        name="password"
        value={user.password}
        onChange={onUserChange}
      />
    </form>
  );
}

And all of a sudden our code feels much cleaner!

Going multiple levels down

Sometimes, our state object is even more complicated. Consider the following example:

function SignupForm() {
  const [user, setUser] = useState({
    email: '',
    password: '',
    settings: { subscribe: false },
  });

  function onUserChange(e) {
    setUser({
      ...user,
      [e.target.name]: e.target.value,
    });
  }

  return (
    <form>
      <label htmlFor="email">Email address</label>
      <input
        id="email"
        name="email"
        value={user.email}
        onChange={onUserChange}
      />
      <label htmlFor="password">Password</label>
      <input
        id="password"
        name="password"
        value={user.password}
        onChange={onUserChange}
      />
      <label>
        <input
          type="checkbox"
          checked={user.settings.subscribe}
          onChange={(e) => {
            // What here?
          }}
        />{' '}
        Subscribe to the newsletter
      </label>
    </form>
  );
}

It turns out we can shallow copy all the way down. Let’s add a custom onChange handler for our checkbox:

<label>
  <input
    type="checkbox"
    checked={user.settings.subscribe}
    onChange={(e) => {
      setUser({
        ...user,
        settings: {
          ...user.settings,
          subscribe: e.target.checked,
        },
      });
    }}
  />{' '}
  Subscribe to the newsletter
</label>

You can see how we use multiple spread operators to shallow copy our object all the way down.

Getting fancy with a deep object change handler

If we want, we can get fancy with our previously-created onUserChange function to handle values any level down our state object:

function SignupForm() {
  const [user, setUser] = useState({
    email: '',
    password: '',
    settings: { subscribe: false },
  });

  function onUserChange(e) {
    const path = e.target.name.split('.');
    const finalProp = path.pop();
    const newUser = { ...user };
    let pointer = newUser;
    path.forEach((el) => {
      pointer[el] = { ...pointer[el] };
      pointer = pointer[el];
    });
    pointer[finalProp] =
      e.target.type === 'checkbox' ? e.target.checked : e.target.value;
    setUser(newUser);
  }

  return (
    <form>
      <label htmlFor="email">Email address</label>
      <input
        id="email"
        name="email"
        value={user.email}
        onChange={onUserChange}
      />
      <label htmlFor="password">Password</label>
      <input
        id="password"
        name="password"
        value={user.password}
        onChange={onUserChange}
      />
      <label>
        <input
          type="checkbox"
          name="settings.subscribe"
          checked={user.settings.subscribe}
          onChange={onUserChange}
        />{' '}
        Subscribe to the newsletter
      </label>
    </form>
  );
}

We assume the name of our target will represent the path down the state object. For example, email and password are at the top level of the object whereas subscribe is under settings (settings.subscribe).

Our change handler splits the target’s name by . and then dives down the object, shallow copying each provided path. It sets the final prop to the target’s value (or checked if the target’s type is checkbox).

Is this worth it?

It’s up to you whether this abstraction is worth it. A single change handler could be really nice if you have a long form, but it also relies on the dot notation convention that developers could forget or get wrong. Additionally, I don’t think this would easily play well with Typescript.

Conclusion

As you can see, there are a few different ways to go about updating nested state in React—some relatively simple and some a bit more complicated. Regardless of the route you take, just make sure you’re not mutating previous state as React won’t re-render a state with the same object reference as before.

Nick Scialli

Nick Scialli is a senior UI engineer at Microsoft.

© 2024 Nick Scialli