While working on a chat application for a client, where at any given moment, new data (such as new messages, online/offline status changes on users, new conversations, etc...) could be received by Redux to update component props and affect the UI in many different ways -- I noticed some really odd behavior. The screen was constantly rendered without end, every few seconds. The re-rendering seemed to be tied with new data coming in due to some user activity. The odd part was that, data unrelated to the screen I was working on was causing re-renders. Data that I was using in the screen was not changing at all, yet something was triggering a React props update. I could not understand what was going on. Redux must be busted, I thought -- it is making my screen think new data came in when it clearly didn't.
After a lot of debugging and testing it slowly dawned on me that Redux was not the problem, and I was doing something fundamentally wrong without realizing it.
I want to write about something so dead-simple that it is easy to get wrong when working with Redux and React -- controlling prop changes.
We all know how it works when we use Redux -- set some data to a reducer, and any component connected to Redux and referencing that piece of data will have its shouldComponentUpdate
lifecycle method called if/when the reducer's value changes.
In terms of Redux, shouldComponentUpdate
runs only if new props are updated in your component. In addition, your component may do some transformations to the props within the component, before rendering them. The props themselves may have data you already have, but React doesn't know it. Once props are sent to your component it is up to the component to determine whether to re-render/process or not. Since React can't determine of the props have actually changed, the component will be forced to process and re-render even if it wasn't needed.
The focus of this post is to reduce the load of work on our component, and try to determine whether to send new props from Redux to the component, or not, to begin with.
If we are using a pure component, shouldComponentupdate
method will shallow compare the old props/old state vs new props/new state. If it finds something has changed it will return true, which tells React to re-render the component. If it did not find any change, it will return false, which tells React to not re-render the component. Whether a component re-renders or not depends on what this method returns.
If we are not using a pure component, whenever new props come in, it will re-render automatically. However, whether it re-renders or not still depends on a change of some kind, it is just more lenient as to what that change might be, which can get us into trouble.
Whether you are using a pure component or not, will probably not matter for this post. You will most likely see the same behavior I want to talk about, no matter what you use.
Let's assume we are not using a pure component, and any prop change will cause a re-render. Like I said, the attempt here is to mitigate the re-rendering issue at the start of the update flow from Redux to component.
A simple example of setting some data to the store may look like the following:
export function myReducer(state = { theData: {} }, action) {
switch (action.type) {
case SET_VALUE:
return {
...state,
theData: {
...state.theData,
...{ [action.payload.key]: action.payload.value },
},
};
}
return state;
}
action.payload.key
will be set for our key object (myKey
for example). action.payload.value
will be the value of our key (1
for example).
And, let's assume we reference this data in our component this way:
const mapStateToProps = (state) => {
const { myReducer } = state;
return {
myReducer: myReducer,
};
};
So, what if we pass again the same value of myKey
and 1
into this reducer, so that the same exact key is set with the same exact value for that key as before? Would our component think new data has come in and re-render? The answer is yes, it would. The reason for this is because even though the values remained the same, the object holding the values is different from the previous. The new object is fed into our React component, and React assumes it is new. The new data will in turn, cause a re-render.
To solve our issue with new array references, we should first ask ourselves, do we need to set a new object each and every time? If not, you could update your reducer to use the previous object, if the new value created no real change:
export function myReducer(state = { theData: {} }, action) {
switch (action.type) {
case SET_VALUE:
const isEqual =
state.theData[action.payload.key] === action.payload.value;
return {
...state,
theData: isEqual
? state.theData
: {
...state.theData,
...{ [action.payload.key]: action.payload.value },
},
};
}
return state;
}
With this simple equality check, we don't have to provide a new object, we just use the same one which has not changed, and we will avoid invoking lifecycle methods in our component -- hmm, or will we?
With our updates in place, if we ran this code in an application and updated our store a few times, you will find this did nothing to fix our issue. It is still rendering unnecessarily. Is Redux or React at fault? Definitely not :) This just brings us to our next "gotcha". Let's re-visit our state to props mapping:
const mapStateToProps = (state) => {
const { myReducer } = state;
return {
myReducer: myReducer,
};
};
Earlier, we mapped our reducer to our component. Our entire reducer object is mapped. Now, per Redux architecture best-practices, we should always return a new state object to avoid mutating our data. We can't really get away with returning a new reducer object, but what we can do is be sure we target exactly what we need in the reducer's hierarchy, instead of just referencing the top level object. Let's refactor our state to props mapping:
const mapStateToProps = (state) => {
const { myReducer } = state;
return {
theData: myReducer.theData,
};
};
Now we're talking! We targetted our reducer's theData
property directly, which may return the same object or not, depending on our simple check we added earlier. With this change, we will cut down on re-renders significantly.
Just imagine if we had a long list of reducers, referenced at the top level, and all returning new objects each and every time something triggered them. In large scale applications this optimization can really help your app stay smooth, and even more so if your component does a lot of data transformations (within componentWillReceiveProps
, for example)! Each modification to the Redux store could cause transformations to run over and over, for multiple components. I cringe at the thought ?
So we've solved a couple things, and our app is running nicely. But now, our app is scaling up, and we need to pass more complex data into our reducer, which becomes more difficult to check for equality. Let's update our reducer to store this new data:
export function myReducer(state = { theData: {}, theComplexData: {} }, action) {
switch (action.type) {
case SET_VALUE:
const isEqual =
state.theData[action.payload.key] === action.payload.value;
return {
...state,
theData: isEqual
? state.theData
: {
...state.theData,
...{ [action.payload.key]: action.payload.value },
},
};
case SET_COMPLEX_CALUE:
return { ...state, theComplexData: action.payload };
}
return state;
}
Let's assume the payload for this complex data is an array of numbers. How can we keep something like this from returning a new array if the values are all the same. For this, we should use a tool like Lodash's isEqual utility to do deep comparisons.
Yes, I know you have probably heard. Deep comparisons are not recommended. Comparing objects in a component deeply can be processor intensive and can cause performance issues. And that's true, you should not deeply compare two objects unless it is absolutely necessary. However, the performance hit could be kept to a minimum if we do this check in the reducer and not on the component's shouldComponentUpdate
method. Leaving it up to the component would cause this check to run too many times, not only due to prop updates, but maybe state updates within your component as well, while doing this in the reducer will only do the call when a new value is set into it, which tends to be a more controlled event. So yes, it's not recommended to deep compare, but if we absolutely have to, this is a good location to keep in mind for doing such work.
export function myReducer(state = { theData: {}, theComplexData: {} }, action) {
switch (action.type) {
case SET_VALUE:
const isEqual =
state.theData[action.payload.key] === action.payload.value;
return {
...state,
theData: isEqual
? state.theData
: {
...state.theData,
...{ [action.payload.key]: action.payload.value },
},
};
case SET_COMPLEX_CALUE:
return {
...state,
theComplexData: _.isEqual(state.theComplexData, action.payload)
? state.theComplexData
: action.payload,
};
}
return state;
}
Redux can be deceptively simple. We can get caught up in the wiring up of things and forget to check these areas that could affect our app. I learned the hard way, but I hope I could spare you from making the same mistakes :)