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 is a senior UI engineer at Microsoft.