A fundamental concept when managing state in JavaScript is that you should never mutate the data directly. In large applications, respecting this rule can become difficult when state is stored in nested objects. This is particularly relevant if you are using libraries such as Redux, as the docs suggest:
The key to updating nested data is that every level of nesting must be copied and updated appropriately. This is often a difficult concept for those learning Redux, and there are some specific problems that frequently occur when trying to update nested objects. These lead to accidental direct mutation, and should be avoided.
In order to avoid mutating state directly, we need to make a copy of the object, modify it as appropriate, and then use it in place of the original. This is the principle behind React's setState method, which accepts an object which it will swap for the existing one in your component's state.
Reference vs. value types in JavaScript
JavaScript objects are data types which are passed by reference to the location in memory, as opposed to strings or integers which are passed by their actual value. This means that copying objects can be tricky, because assignment might not work as you expect.
Take this example of a user object:
const state = {
name: 'John',
address: {
city: 'London',
country: {
countryName: 'United Kingdom',
countryCode: 'UK',
},
},
};
We cannot make a copy of this object by assigning it to a new variable:
const copyState = state;
copyState.name = 'Jane';
console.log(copyState === state); // true
console.log(state.name); // 'Jane'
The copyState variable is pointing to the same reference as the original state object, which is why the strict equals check returns true. When we modify the name property of the copyState object, we are mutating the same object which the state variable is pointing to. Often this is not what is intended.
Spread operator
The spread operator or syntax (...) can be used to make a shallow copy of an object.
const shallowCopyState = { ...state };
shallowCopyState.name = 'Jane';
console.log(shallowCopyState === state); // false
console.log(state.name); // 'John'
Now, our two variables are pointing to different object references. Modifying the value of the name property on the shallowCopyState object has no effect on the original state object and the strict equals check returns false.
Shallow in this context means that for any given object that is spread, the uppermost level of the new variable is an object containing the same properties and values of the original object, but at a new reference in memory. Any lower level or nested objects, however, will remain pointing to their original references:
const shallowCopyState = { ...state };
console.log(shallowCopyState === state); // false
shallowCopyState.address.city = 'Paris';
console.log(shallowCopyState.address === state.address); // true
console.log(state.address.city); // 'Paris'
To copy a deep object like our user object safely, we also need to use the spread operator at the nested level of the object:
const deeperCopyState = {
...state,
address: {
...state.address,
},
};
deeperCopyState.address.country.countryCode = 'FR';
console.log(deeperCopyState.address === state.address); // false
console.log(deeperCopyState.address.country === state.address.country); // true
console.log(state.address.country.countryCode); // 'FR'
As you can see in the above example, the nested object for address is different across the two variables, but its nested object for country is still pointing to the same reference as in our original state variable. We could fix this by going down further, but at this point it may be easier to reach for a library to help us, such as Immer.
Immer
The Immer library consists of a produce function which takes an existing object and returns a new one. Because you can also dictate which properties on the new object will be updated, it's an excellent way of safely creating state objects:
const state = {
name: 'John',
address: {
city: 'London',
country: {
countryName: 'United Kingdom',
countryCode: 'UK',
},
},
};
const immerState = immer.produce(state, draftState => {
draftState.name = 'Jane';
draftState.address.city = 'Paris';
draftState.address.country.countryName = 'France';
draftState.address.country.countryCode = 'FR';
});
The immerState variable is completely decoupled to the original state object, and shares no references to it:
console.log(immerState === state); // false
console.log(immerState.address === state.address); // false
console.log(immerState.address.country === state.address.country); // false
console.log(state.address.country.countryCode); // 'UK'
console.log(immerState.address.country.countryCode); // 'FR'
Finally
It's worth referring back to the Redux docs about nested objects:
Obviously, each layer of nesting makes this harder to read, and gives more chances to make mistakes. This is one of several reasons why you are encouraged to keep your state flattened.
If you find yourself handling objects that are many levels deep, and which require extensive use of the spread operator or a library like Immer, it is worth considering if there's a way to simplify the composition of such objects. If, however, you find yourself in a codebase where these structures are common, hopefully this article will help you keep your state immutable.
Top comments (2)
I just wanted to say that this just saved my life (metaphorically speaking, of course)! I was using the spread operator to make a deep copy whereas it was only making a shallow copy. That was causing me to mutate the state * crowd gasps *.
lodash.cloneDeep
might be worth considering also for just this task.