59

Improve Performance in React Class Components using React.PureComponent

 5 years ago
source link: https://www.tuicool.com/articles/hit/nANnyie
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
vqie2mU.jpg!webzmqeEff.jpg!web

At the release of React v16.6, it heralded great advancements on how we use Reactjs. One of the great additions to React v16.6 is the React.PureComponent class.

In this post, we will look in depth at what React.PureComponent and the goodies it brings us. Read on.

Tip: Building with components? you should probably make them reusable to share across apps. Open-source tools like Bit can help you out, take a look:

Early On: Before the Advent

We used to write our components like this:

import React from 'react';
class Count extends React.Component {
    constructor(props) {
        super(props)
        this.state = {
            count: 0
        }
    }
    render() {
        return ( 
            <div >
            <h1> Count Component { this.state.count } </h1> 
            <input onChange = {
                (evt) => {
                    console.log(evt.target.value)
                    const val = evt.target.value
                    if(val !== "")
                     this.setState({ count: val })
                }
            } placeholder = "Enter a Count..." />
            </div>
        )
    }
}
export default Count;

Components have a render method which returns the JSX markup that it renders to the DOM. For change detection, React use a local state, which is only local to a component, when the state changes the component and its children are re-rendered to update the UI of the changed state. Then we have the props which is a short form of properties for passing attributes to components, it is received in the constructor as this.props.

Now, this component has a count state with an initial value of 0, when we enter a number in the input box the `onchange event is fired, the current value in the input box is used to update the count state. If we enter 1, the count state is updated to 1, when we enter 234, the count state will be updated to 234.

RrMfEbZ.jpgRNFjAbU.gif

When we enter a certain number and re-enter the same number again. Should the DOM of the component re-rendered? No, of course not, that will lead to slowdowns in our apps.

But here in our component, the component will be re-rendered even if the previous state and the current state is the same value.

3e2ARvr.jpgfaUZvum.gif

To visualize this re-renders with React Devs Tools. Open your DevsTools and click on the React tab

YVrM73Z.jpg!webRvieA3Y.jpg!web

click on the HighLight Updates check-box. If the HighLight Updates check-box is not available, click on the Settings box on the right side, a popup will appear, then click on HighLight Updates .

If we now interact with our app, we will see color highlights momentarily appear around any components that render or re-render.

Considering the component above, if we click the input and type in 1 the component will re-render to reflect the value 1 on the DOM. We will see the borders around the Count component flash color(blue in my machine).

b2uu22Y.jpgBriARfI.gif

Now, if we type 2 there will be a flash, then we type 2 again. there shouldn’t be a flash but it occurred.

fm6ramF.jpgiYJFZ3R.gif

We know this is unnecessary because the previous value didn’t change. We know this is a small app and we can do with the re-renderings, but imagine a complex app with thousands of re-renders imagine how sluggish your app will look, you see the slowdowns will become noticeable. We can throw a little salt in our app to boost its performance by adding the lifecycle method, shouldComponentUpdate .

shouldComponentUpdate

This method tells React whether to update a component or not, if it is to update a component it returns true, if not it returns false.

function shouldComponentUpdate(nextProps, nextState) {
    //...
}

The basic skeleton of the function, it takes the next props values in the nextProps parameter and the value of the next state in the nextState parameter.

Applying this to our Count component:

class Count extends React.Component {
    constructor() {
        this.state = {
            count: 0
        }
    }
shouldComponentUpdate(nextProps, nextState) {
        if(nextState.count !== this.state.count) {
            return true
        }
        return false
    }
render() {
        return ( 
            <div >
            <h1> Count Component { this.state.count } </h1> 
            <input onChange = {
                (evt) => {
                    console.log(evt.target.value)
                    const val = evt.target.value
                    if(val !== "")
                     this.setState({ count: val })
                }
            } placeholder = "Enter a Count..." />
            </div>
        )
    }
}

We have now regulated the updates to occur when there is a new value. In the above implementation, we checked if the value of the count property in the next state is not the same as the one currently in the count property. If they are not the same we tell React to render by returning true, if not we tell React not to re-render by returning false.

Looking at our React DevTools, we will see that when we type in 2 there will be a flash and type it in again they will be no color flashing around the Count component :) this shows no re-rendering.

zaErI3F.jpgVj2eQ3F.gif

Now, instead of writing shouldComponentUpdate() by hand, you can inherit from React.PureComponent.

React.PureComponent

Instead of writing shouldComponent method in our components, React introduced a new Component with built-in shouldComponentUpdate implementation it is the React.PureComponent component.

It ( React.PureComponent ) is equivalent to implementing shouldComponentUpdate() with a shallow comparison of current and previous props and state. - Reactjs Blog

What this means that we can simply remove the shouldComponentUpdate and make our component extend React.PureCompoent that it has already implemented the shouldComponentUpdate for us internally with shallow comparison (we will come to that).

So, now we edit our Count component to extend React,PureComponent:

class Count extends React.PureComponent {
    constructor() {
        this.state = {
            count: 0
        }
    }
/*shouldComponentUpdate(nextProps, nextState) {
        if(nextState.count !== this.state.count) {
            return true
        }
        return false
    }*/
render() {
        return ( 
            <div >
            <h1> Count Component { this.state.count } </h1> 
            <input onChange = {
                (evt) => {
                    console.log(evt.target.value)
                    const val = evt.target.value
                    if(val !== "")
                     this.setState({ count: val })
                }
            } placeholder = "Enter a Count..." />
            </div>
        )
    }
}

Notice we commented out the shouldComponentUpdate method. Now, if look at our React DevTools you will see that if we type in 4 and type it in again there will be no flash.

Yr63yiB.jpgFBB3AbB.gif

claps

aUFRbqF.jpgMVVVB3A.gif

React.PureComponent always does a shallow comparison of values, we will run into problems if we have a complex data structure like Arrays and Objects. You see we can’t use it if the props or state may have been mutated in a way that a shallow comparison would miss.

Let’s edit our Count component to include a complex data structure:

import React from 'react';
class Count extends React.PureComponent {
    constructor(props) {
        super(props)
        this.state = {
            count: [0]
        }
    }
    componentDidUpdate(prevProps, prevState) {
        console.log("componentDidUpdate")
    }
    componentWillUpdate(nextProps, nextState) {
        console.log("componentWillUpdate")
    }
        /*shouldComponentUpdate(nextProps, nextState) {
        if(nextState.count !== this.state.count) {
            return true
        }
        return false
        }*/
render() {
        return ( 
            <div >
            <h1> Count Component { this.state.count } </h1> 
            <input onChange = {
                (evt) => {
                    console.log(evt.target.value)
                    const val = evt.target.value
                    if(val !== ""){
                        const count = this.state.count
                        count.push(val)
                        this.setState({ count: count })
                  
                    }
                }
            } placeholder = "Enter a Count..." />
            </div>
        )
    }
}
export default Count;

This code won’t trigger a re-render

6FJ7JvY.jpgJbMJra2.gif

upon the value of the count property changed. The problem comes from that React.PureComponent does a shallow comparison of the state values.

Let’s say we entered 4. The prev value of count will be [0] while the next value will be [0, 4] but the component didn’t re-rendered yet the two values are different. The problem is from mutation of data.

The Immutability Power

Mutation has been a very big issue in JS. Mutating data in Js might lead to distrust that the data may have been changed somewhere in the app by unlikely sources. Best practices always tell us not to mutate data in our app.

As PureComponeny does shallowCompare it uses the equality operator to check for sameness in values. Now, if we mutate our arrays using the push method. The === equality operator doesn’t detect a change in the address of the array.

Don’t understand?

Now, data structures are stored by their references to their memory addresses. All what === does is to check the memory address for sameness.

let a = [90]
let b = [90]
0  |    | 14 |    |
1  |    | 15 |    |
2  |    | 16 |    |
3  | a  | 17 |    |
4  | b  | 18 |    |
5  |    | 19 |    |
6  |    | 20 |    |
7  |    | 21 | 90 |
8  |    | 22 |    |
9  |    | 23 |    |
10 |    | 24 |    |
11 | 90 | 25 |    |
12 |    | 26 |    |
13 |    | 27 |    |

a and b refers to different memory address. a might refer to cell #0011 and b might point to cell #0021. When we do:

a.push(88)

The push method doesn’t create another memory to store 90 and the pushed 88. It goes to the memory pointed to by a and updates the values

0  |          | 14 |    |
1  |          | 15 |    |
2  |          | 16 |    |
3  | a-> 11   | 17 |    |
4  | b-> 21   | 18 |    |
5  | p -> 11  | 19 |    |
6  |          | 20 |    |
7  |          | 21 | 90 |
8  |          | 22 |    |
9  |          | 23 |    |
10 |          | 24 |    |
11 | 90       | 25 |    |
12 | 88       | 26 |    |
13 |          | 27 |    |

Here now, a has been mutated meaning that the nature/state/structure of it has been altered(just like changing the base pairs of a DNA).

The address of a stills points to 11, === doest check the values stored in the address it checks whether the address of the LHS array is the same with the address of RHS.

let a = [90]
let p = a
let b = [90]
a.push(88)
log(a===p)
// true

If we use a non-mutating method like concat

let a = [90]
let p = a
let b = [90]
p = a.concat([88])
log(a===p)
// false

concat creates a new array in memory and appends the value and returns the new array.

0  |        | 14 |    |
1  |        | 15 |    |
2  |        | 16 |    |
3  | a-> 11 | 17 |    |
4  | b-> 21 | 18 |    |
5  | p-> 25 | 19 |    |
6  |        | 20 |    |
7  |        | 21 | 90 |
8  |        | 22 |    |
9  |        | 23 |    |
10 |        | 24 |    |
11 | 90     | 25 | 90 |
12 |        | 26 | 88 |
13 |        | 27 |    |

a points to 11, p points to 25 two different memory addresses. We see concat doesn't touch the old array, it creates a new one and returns it leaving the original one unchanged.

So we see with this we can detect that something changed in the values because of the change in the memory addresses.

This is also evident in their C++ impl. in v8:

BUILTIN(ArrayPush) {
  HandleScope scope(isolate);
  Handle<Object> receiver = args.receiver();
  // ...
  Handle<JSArray> array = Handle<JSArray>::cast(receiver);
 //...
  ElementsAccessor* accessor = array->GetElementsAccessor();
  int new_length = accessor->Push(array, &args, to_add);
  return Smi::FromInt(new_length);
}
BUILTIN(ArraySplice) {
  HandleScope scope(isolate);
  Handle<Object> receiver = args.receiver();
  // ...
  Handle<JSArray> array = Handle<JSArray>::cast(receiver);
  Handle<JSArray> result_array = accessor->Splice(
      array, actual_start, actual_delete_count, &args, add_count);
  return *result_array;
}

The JSArray is the C++ class that describes JS arrays. See in Push: The JSArray is cast from the receiver, then The value is pushed to the JSArray by calling acessor->Push(...) and a new length is returned. In the Splice: see a new array Handle<JSArray> result_array is returned.

Now we have seen in entirety how mutation affects equality check. To tell the equality check that your data structure changed we need to return a new array, not to mutate/change the structure of the original data. We see this practice in Redux, whereby the reducers are pure functions and always return new data structure, if no addition or subtraction action is caught, the state is returned as it is without changing the structure:

function reducer (state = initialState, action) {
    switch(action) {
        case "ADD_ACTION":
         // return new state using spread
         return [...state, action.payload]
        case "REMOVE_ACTION":
         // returning a new array using the non-mutating splice method
         return state.splice()
        default:
         // no data change return the same state
         return state
    }
}

We can see the beauty and intelligence behind it. If the state ever changes a new state is returned, if no change is made to the state the same is simply returned so there will be no re-render. That’s the reason react-redux is the most efficient way to trigger and control unnecessary re-renders in React.

Coming back to our Count component, we need to remove the mutating push method and update the count array with a non-mutating method which returns a new array.

import React from 'react';
class Count extends React.PureComponent {
    constructor(props) {
        super(props)
        this.state = {
            count: [0]
        }
    }
    componentDidUpdate(prevProps, prevState) {
        console.log("componentDidUpdate")
    }
    componentWillUpdate(nextProps, nextState) {
        console.log("componentWillUpdate")
    }
        /*shouldComponentUpdate(nextProps, nextState) {
        if(nextState.count !== this.state.count) {
            return true
        }
        return false
        }*/
render() {
        return ( 
            <div>
            <h1> Count Component { this.state.count } </h1> 
            <input onChange = {
                (evt) => {
                    console.log(evt.target.value)
                    const val = evt.target.value
                    if(val !== ""){
                        this.setState({ count: this.state.count.concat([val]) })
                    }
                }
            } placeholder = "Enter a Count..." />
            </div>
        )
    }
}
export default Count;

We used the concat method instead of push. It returns a new array, so when the equality runs on it, the two states will have different memory references and it will trigger a re-render.

jYVNZfe.jpgBBZZFbi.gif

Angular Counterpart: OnPush

You can find a similar thing in Angular: OnPush strategy not really the same, but an Angular component with the OnPush Strategy is only rendered on the first rendering of the app, on the next renderings it won’t be rendered by Angular, in fact, it will be skipped.

It will only be run when:

markForCheck

You see it is like React.PureComponent, OnPush checks for unnecessary re-renders.

If we port our React Count component to Angular it will look like this:

import { Component, OnInit } from '@angular/core';
@Component({
    selector: 'app-count',
    template: `
        <div>
            {{viewRun}}
            <h1> Count Component {{count}} </h1>
            <input (change)="onChange($event)" placeholder="Enter a Count..." />
        </div>    
   `
})
export class Count implements OnInit {
    count = 0
    onChange(evt) {
        console.log(evt.target.value)
        const val = evt.target.value
        if(val !== "")
            this.count= val
    }
ngOnInit() {}
get viewRun() {
    console.log('view updated:', this.count)
    return true
  }
}

Here, if we type in a number like 6, there will be an update, view updated: 6 will be logged in the screen. If we clear the number 6 and retype 6, view updated: 6 will be logged again in the screen. The old value is 6 and the new value is 6, there is no suppose to be an update because the old value and new value is the same.

You see that is the same that we encountered in our React version. Angular doesn’t have React’s shouldComponentUpdate lifecycle hook equivalent to tell Angular when to update the component. But rather have it in its OnPush CD strategy.

import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core';
@Component({
    selector: 'app-count',
    template: `
        <div>
            {{viewRun}}
            <h1> Count Component {{count}} </h1>
            <input (change)="onChange($event)" placeholder="Enter a Count..." />
        </div>    
   `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class Count implements OnInit {
    count = 0
    onChange(evt) {
        console.log(evt.target.value)
        const val = evt.target.value
        if(val !== "")
            this.count= val
    }
  ngOnInit() {  }
  get viewRun() {
    console.log('view updated:', this.count)
    return true
  }
}

When an input is changed, a CD run is triggered. On every OnPush components, Angular checks if the old values and the new values are different before allowing the CD run on the OnPush component. If the old values and the new values are the same, Angular cancels the CD on the OnPush components. This is evident in our Count component above which is now an OnPush component. If we type in 99, there will be an update run on the component, if we clear the number and enter it again, there will be no update.

So, OnPush makes the component “pure” just like React.PureComponent, no wasted renders.

Angular’s change detection mechanism is the same as React.PureComponent they both use shallow compare. It uses the equality check === to check for sameness.

// angular/src/util.ts
function looseIdentical(a: any, b: any) {
    return a === b
}

This is the function called by Angular during change detection run to check for changes.

So, we should also be aware of mutation in Angular OnPush components because the === equality operator checks for reference changes in data structures.

// ...
@Component({
    selector: 'app-count',
    template: `
        <div>
            {{viewRun}}
            <h1> Count Component {{count}} </h1>
            <input (change)="onChange($event)" placeholder="Enter a Count..." />
        </div>    
   `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class Count implements OnInit {
    count = []
    onChange(evt) {
        console.log(evt.target.value)
        const val = evt.target.value
        if(val !== "")
            this.count.push(val)
    }
  ngOnInit() { }
  get viewRun() {
    console.log('view updated:', this.count)
    return true
  }
}

The above will not trigger a CD run on the component when a number is entered in the input box.

Why?because the new value reference will still point to the old value.

We should always return new data structures or use methods or practices that don’t mutate the original data. To make the component re-render when a new value is added to the count array we will use the concat method which returns a new array on each call:

// ...
@Component({
    selector: 'app-count',
    template: `
        <div>
            {{viewRun}}
            <h1> Count Component {{count}} </h1>
            <input (change)="onChange($event)" placeholder="Enter a Count..." />
        </div>    
   `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class Count implements OnInit {
    count = []
    onChange(evt) {
        console.log(evt.target.value)
        const val = evt.target.value
        if(val !== "")
            this.count = this.count.concat([val])
    }
  ngOnInit() { }
  get viewRun() {
    console.log('view updated:', this.count)
    return true
  }
}

Looking at the source

Let’s peep into the React source to see how this works internally.

First, we look at where our base components are defined: react-master\packages\react\src\ReactBaseClasses.js

// react/src/ReactBaseClasses.js
// ...
/**
 * Convenience component with default shallow equality check for sCU.
 */
function PureComponent(props, context, updater) {
    // ...
}
const pureComponentPrototype = (PureComponent.prototype = new ComponentDummy());
pureComponentPrototype.constructor = PureComponent;
// Avoid an extra prototype jump for these methods.
Object.assign(pureComponentPrototype, Component.prototype);
pureComponentPrototype.isPureReactComponent = true;
export {Component, PureComponent};

We can see the PureComponent above, the interesting part is the isPureReactComponent property, it denotes that the component is a pure component. Let's look at what happens when a component is checked for updating:

function checkShouldComponentUpdate(
  workInProgress,
  ctor,
  oldProps,
  newProps,
  oldState,
  newState,
  nextContext,
) {
  const instance = workInProgress.stateNode;
  if (typeof instance.shouldComponentUpdate === 'function') {
    startPhaseTimer(workInProgress, 'shouldComponentUpdate');
    const shouldUpdate = instance.shouldComponentUpdate(
      newProps,
      newState,
      nextContext,
    );
    stopPhaseTimer();
if (__DEV__) {
        //...
    }
return shouldUpdate;
  }
if (ctor.prototype && ctor.prototype.isPureReactComponent) {
    return (
      !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
    );
  }
return true;
}

This function is where the shouldComponentUpdate method is called in React components. It first checks if the function exists and is a function, next it calls it passing in the next props and next state values. The return value is stored in shouldUpdate and returned.

Looking down we see where a check for PureComponet is made. See the isPureComponent property we saw earlier is used to check if the component is a PureComponent. If yes, the built-in comparison method shallowEqual is called. The function is shallowCompare, the function that is called on all PureComponents, remember PureComponents leaves us without the need to add shouldComponentUpdate check because it been run for us in the shallowCompare function.

Conclusion

We looked into so many concepts in this post:

  • What mutation entails
  • Function purity
  • Performance increment the use of sholudComponentUpdate
  • Performance increment the use of React.PureComponent

The performance boost that React.PureComponent brings to the table is freaking awesome.

If you have any question regarding this or anything I should add, correct or remove, feel free to comment, email or DM me.

Thanks !!!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK