We're well aware of redux's need for immutability. Most of us have an immutability tool to manage our state easily. It's even the first redux best practice: Do Not Mutate State.
But have you ever wondered what would actually happen if you mutated your state? Should we try?
Before rushing into mutation, let's first check out what makes sure our react component re-renders after a regular state update.
In his article, Mark Erikson gives a simplified version of the useSelector hook code, which is a perfect starting point to understand the danger of mutation.
++pre>++code data-line-start="67" data-line-end="90">function useSelector(selector) {
const [, forceRender] = useReducer( counter => counter + 1, 0);
const {store} = useContext(ReactReduxContext);
const selectedValueRef = useRef(selector(store.getState()));
useLayoutEffect(() => {
const unsubscribe = store.subscribe(() => {
const storeState = store.getState();
const latestSelectedValue = selector(storeState);
if(latestSelectedValue !== selectedValueRef.current) {
selectedValueRef.current = latestSelectedValue;
forceRender();
}
})
return unsubscribe;
}, [store])
return selectedValueRef.current;
}
++/code>++/pre>
Here is what the code does:
useSelector keeps a ref of the selector ran on the state. It also subscribes to the store updates which are released every time the store is updated, after a dispatch, and thus after the reducer function is run.
On each store update, useSelector makes the component re-render only if the value we are interested in has changed, to avoid a useless re-render. To know whether our value has changed or not, it runs the selector on the new state, and compares this value to the previous one it stored in its ref.
This is what our useSelector does, and how a re-render is triggered after a dispatch, based on whether the value we are interested in the store was changed or not.
Don't panic if this wasn't perfectly clear to you. Let's take a look at a simple example to put that into practice.
Say that in our store, we have ++code>myNumber++/code>, a number initially equal to 0. We can access it through a selector: ++code>const myNumberSelector = state => state.myNumber;++/code>
When we first render our component, we call useSelector, which runs ++code>myNumberSelector++/code> on our state. It returns 0, and useSelector stores 0 in a ref.
We dispatch the ++code>UPDATE_MY_NUMBER++/code> action, which is handled this way in the reducer:
++pre>++code data-line-start="111" data-line-end="117">case UPDATE_MY_NUMBER:
return {
...state,
myNumber: state.myNumber + 1,
};
++/code>++/pre>
This dispatch triggers a call on all subscribers, including our useSelector, which runs the selector on the new state and gets a 1 this time.
To compare this value to the previous one we stored in a ref, useSelector uses the strict equality check (===).
As 0!==1, useSelector stores 1 in the ref and triggers a re-render on the react component, it displays the value returned by useSelector, which is 1, the value of the ref.
Now, what would happen if we mutated our number?
Say the dispatched action is now handled this way by the reducer:
++pre>++code data-line-start="127" data-line-end="131">case MUTATE_MY_NUMBER:
state.myNumber = state.myNumber + 1
return state;++/code>++/pre>
We still have a 0 in our ref, the selector ran on the new state returns 1 again, and 0!==1. useSelector still triggers a re-render. Everything works perfectly.
What happened here? You may have noticed that we did not actually mutate myNumber. We reassigned it. We mutated the state object by changing one of its properties, but we didn't mutate the myNumber property. Mutating the state by reassigning one of its root properties should not affect redux's behavior.
Disclaimer: Even though a state's property reassignment should not have any consequence, I am not encouraging to mutate the state this way. I just tried to break things down to be more aware of the impact of mutation on redux and to understand why we should avoid it. Mutation is still a bad practice.
Actually, we could never mutate myNumber, because it is a primitive: primitives are immutable. If all of this isn't really clear for you, and you don't really see any difference between reassigning and mutating, I suggest this article that explains all of this very well.
Our journey on the understanding of the impact of mutation on redux's behavior isn't over yet though. Apart from primitives, what other type of data do we have in JavaScript that we could mutate? Objects!
You might already be aware that, in JavaScript, everything that isn't a primitive is an object. Arrays are objects. So we'll study the mutation of an array.
But before rushing into an array's mutation, let's try to update it properly first.
Let's put ++code>myArray++/code> in our store, an array initially equal to ++code>[1, 2, 3]++/code>, with the corresponding selector: ++code>const myArraySelector = state => state.myArray;++/code>
When we call it, useSelector stores ++code>[1, 2, 3]++/code> in a ref. Or more precisely, it stores the address of the memory location where the array was created in the first place. What is important here, is that both the ++code>myArray++/code> property of the state and the ref point towards the same object.
We dispatch ++code>UPDATE_MY_ARRAY++/code> which is properly handled this way by the reducer:
++pre>++code data-line-start="149" data-line-end="155">case UPDATE_MY_ARRAY:
return {
...state,
myArray: [...state.myArray, 4]
}
++/code>++/pre>
useSelector runs ++code>myArraySelector++/code> on the new state and gets a new array. Indeed, the spread operator creates a completely new object. This array returned by the selector is not the same anymore as the one that was stored in the ref, they are two different objects, stored in two different places in the memory.
This is the information that matters when performing a strict equality check on the ref and the result of the selector. The strict equality operator checks whether two objects point towards the same object, the same memory location. As here it is not the case, they are different through strict equality. And as they are different, useSelector triggers a re-render.
What if our dispatched action was handled this way by the reducer: ++code data-line-start="149" data-line-end="155"> myArray: [...state.myArray]++/code>, creating a new array with no change? Well, Our component would still re-render. Indeed, even if the array was not modified, we still create a new array through the spread operator, which is not the same one as the one the ref refers to. So the useSelector triggers a re-render.
Now, what would happen if we mutated our array instead of returning a new one?
This time, we dispatch ++code>MUTATE_MY_ARRAY++/code> which is handled with a mutation by the reducer:
++pre>++code data-line-start="165" data-line-end="169">case MUTATE_MY_ARRAY:
state.myArray.push[4];
return state;
++/code>++/pre>
Instead of creating a new array, we mutate ++code data-line-start="149" data-line-end="155">myArray++/code>. The array retrieved by useSelector after the dispatch still points towards the same array as the one that was stored in the ref. As they point towards the same array, the same memory location, they are equal through strict equality, so useSelector won't trigger a re-render.
And this is the danger of mutation: you will not see the latest value from your store, but the one you had before you mutated your store, as no re-render was meanwhile triggered. As a consequence, you may have an inconsistent UI.
One may wonder, would it work if we used deep equality? What if we went through the whole array in-depth, comparing each value one by one? Indeed, useSelector accepts an equality function as an argument. So we could pass on to our hook lodash's isEqual function, for example. This way, useSelector could see that the two arrays are not the same, and be able to trigger a re-render. Unfortunately, it wouldn't work.
When you mutate an object, all the objects that point towards the same object, the same memory location, are mutated as well. So when we mutate ++code>myArray++/code> in our reducer, the ref in useSelector is also modified, as it points towards the same address. So even with a deep equality check, useSelector would not trigger a re-render, as you compare two arrays that have the very same values.
Mutating your state would result in inconsistent UI. But besides that, there are some other consequences. It would break time-travel debugging, an essential development experience functionality that expects you to follow redux's best practices.
It would also result in a less predictable, less explicit and less safe code. Some of the reasons why redux was built on immutability in the first place.
I hope you better understand redux's behavior, and that the two of you will get along perfectly from now on.
In the next article, we will talk about another redux essential best practice: Do Not Put Non-Serializable Values in State or Actions.