War on the brakes. Optimizing the number of component renderings in React Native

Hello, Habr! My name is Kamo Spertsyan, I am engaged in React Native development at Profi.ru. If you decide to use React Native technology to quickly deliver product features and focus on development speed, then you are likely to run into performance issues. At least that's what happened to us. After six months of active development, the performance of our application fell below a critical level - everything was wildly slow. Therefore, we took up the optimization - removed all the “brakes” during startup, transitions between screens, rendering screens, reactions to user actions. As a result, in three months they brought the user experience to the native level. In this article I want to talk about how we optimized the application on React Native and solved the problem of multiple component renders.



I put together recommendations that will help minimize the number of pointless redraws of components. For clarity, in the examples I compare the “bad” and “good” implementations. The article will be useful to those who are already faced with poor application performance, and those who do not want to allow this in the future.

We use React Native paired with Redux. Some of the tips are related to this library. Also in the example, I use the Redux-thunk library - to simulate working with the network.

When to think about performance?


In fact, it is worth remembering from the very beginning of work on the application. But if your application is already slowing down - do not despair, everything can be fixed.

Everyone knows, but just in case, I’ll mention: it’s better to check performance on weak devices. If you are developing on powerful devices, you may not be aware of the “brakes” of end users. Decide for yourself the devices you will be guided by. Measure time or FPS in control plots to compare with results after optimization.

React Native out of the box provides the ability to measure FPS applications through Developer Tools → Show perf monitor. The reference value is 60 frames per second. The lower this indicator, the stronger the application "slows down" - does not respond or reacts with a delay to user actions. One of the main effects on FPS is the number of renders, the “severity” of which depends on the complexity of the components.

Example description


I show all the recommendations on the example of a simple application with a list of news. The application has one screen, which is located FlatListwith the news. A news item is a component NewsItemthat consists of two smaller components - the headline ( NewsItemTitle) and the body ( NewsItemBody). The entire example can be seen here . Further in the text are links to various branches of the repository for specific examples. The repository is used for the convenience of readers who want to explore examples more deeply. The code in the repository and examples below does not claim to be perfect - it is needed solely for demonstration purposes.

Below, all components are schematically shown with links and props.


In the render method of each component, I added the output to the console of unique information about it:

SCREEN
ITEM_{no}
ITEM_TITLE_{no}
ITEM_BODY_{no}

where {no}is the news serial number in order to distinguish between different news renderings from multiple renderings of the same one.

For testing on each refreshnews list, additional news is added to its beginning. At the same time, the following message is displayed in the console:

--------------[ REFRESHING ]--------------

These records will help to understand if there is a problem in any particular component, and later to determine whether it was possible to optimize it.

If implemented correctly, our log after launch and several updates should look like this:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
--------------[ REFRESHING ]--------------
SCREEN
ITEM_4
ITEM_TITLE_4
ITEM_BODY_4

At the first start, the screen itself and two initial news are drawn. When updating the board, the screen is rendered again, because its data has really changed. More news comes up. All previous news is not redrawn, as there was no change in their data.

When is a component rendered?


In React and React Native, there are two conditions for rendering a component:

  1. changing his props / state,
  2. render of the parent component.

A function can be redefined in a component shouldComponentUpdate- it receives new Props and State as an input and tells whether the component should be rendered. Often, to avoid unnecessary re-renders, a shallow compare of Props and State objects is enough. For example, this eliminates unnecessary renders when the parent component changes, if they do not affect the child component. In order not to write a surface comparison manually each time, you can inherit a component from React.PureComponentthat encapsulates this check.

When we use the connect link function, the Redux library creates a new component that is “connected” to the global State. Changes to this State trigger a methodmapStateToPropswhich returns new props. Next, a comparison of old and new props starts, regardless of whether the component was declared as PureComponentor not.

Consider these nuances in our example.

We will NewsItemlet the component pass through connect, NewsItemTitleinherit from React.Component, and NewsItemBody- from React.PureComponent.

→ Full example code

export class NewsItemTitle extends React.Component
export class NewsItemBody extends React.PureComponent

Here's what the log will look like after one board update:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_1
ITEM_TITLE_1

You can see that the news and headline components are redrawn. We will consider them in turn.

NewsItemdeclared using connect. As a props, this component receives an identifier, by which it subsequently receives news in mapStateToProps:

const mapStateToProps = (state, ownProps) => ({
  item: state.newsMap[ownProps.itemKey],
});

Since when updating the board all the news is downloaded again, the object will itemupdate and afterwards will refer to various memory cells. In other words, they will be different objects, even if all the contained fields are the same. Therefore, a comparison of the previous and new State'ov component returns false. The component will be re-rendered, despite the fact that in fact the data has not changed.

NewsItemTitleis inherited from React.Component, so it is re-rendered every time the parent component is rendered. This happens regardless of the values ​​of old and new props.

NewsItemBodyinherited from React.PureComponent, so it compares old and new props. In news 1 and 2, their values ​​are equivalent, therefore the component is rendered only for news 3.

To optimize the renderingsNewsItemTitlejust declare it as React.PureComponent. In the case of, you have NewsItemto redefine the function shouldComponentUpdate:

shouldComponentUpdate(nextProps) {
  return !shallowEqual(this.props.item, nextProps.item);
}

→ Full example code

Here shallowEqualis a feature for surface comparison of objects that Redux provides. You can write like this:

shouldComponentUpdate(nextProps) {
  return (
    this.props.item.title !== nextProps.item.title ||
    this.props.item.body !== nextProps.item.body
  );
}

Here's what our log will look like after this:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3

Note
shouldComponentUpdate NewsItem , NewsItemTitle . . NewsItemTitle - NewsItem, .

React.memo and functional components


It is shouldComponentUpdatenot possible to override in a functional component. But this does not mean that in order to optimize a functional component, you have to rewrite it into a class one. For such cases, the React.memo memoization function is provided . It accepts a component input and an optional comparison function areEqual. When called, it areEqualgets old and new props and should return the result of the comparison. The difference with shouldComponentUpdatewhat areEqualshould return trueif the props are equal, and not vice versa.

For example, NewsItemTitlememoization may look like this:

areEqual(prevProps, nextProps) {
  return shallowEqual(prevProps, nextProps);
}
export OptimizedNewsItemTitle = React.memo(NewsItemTitle, areEqual)

If you do not pass areEqualin React.memo, then a superficial comparison of props will be made, so our example can be simplified:

export OptimizedNewsItemTitle = React.memo(NewsItemTitle)

Lambda functions in props


To process component events, functions can be passed to its props. The most striking example is implementation onPress. Often anonymous lambda functions are used for this. Let's say in NewsItemBodywe want to show only the preview, and if you click on it - the whole text. To do this, when rendering NewsItemin, NewsItemBodywe will pass the following prop:

<NewsItemBody
  ...
  onPress={() => this.props.expandBody()}
  ...
/>

Here's what the log looks like with this implementation when the method shouldComponentUpdateis NewsItemdeleted:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1

News bodies 1 and 2 are rendered, although their data has not changed, but NewsItemBodyis PureComponent. This is due to the fact that for each render the NewsItemvalue of props onPressis created anew. Technically, onPresswith each render, it points to a new area in memory, so a superficial comparison of props in NewsItemBodyreturns false. The problem is fixed by the following entry:

<NewsItemBody
  ...
  onPress={this.props.expandBody}
  ...
/>

Log:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_1
ITEM_TITLE_1

→ Full example code

Unfortunately, an anonymous function can by no means always be rewritten as a method or a class field for such a record. The most common case is when inside the lambda function the scope variables of the function in which it is declared are used.

Consider this case in our example. To switch from the general list to the screen of one news, we add the processing of clicking on the body of the news. The renderItemcomponent method FlatListwill look like this:

const renderItem = ({item}) => (
  <NewsItem
    itemKey={item}
    onBodyPress={() => this.onItemBodyPress(item)}
  />
);

An anonymous function onBodyPresscannot be declared in a class, because then the variable itemthat is needed to go to a specific news will disappear from the scope .

The simplest solution to the problem is to change the signature of the onBodyPresscomponent props NewsItemso that the required parameter is passed to the function when called. In this case, this is the news identifier.

const renderItem = ({item}) => (
  <NewsItem
    itemKey={item}
    onBodyPress={item => this.onItemBodyPress(item)}
  />
);

In this case, we can already take out the anonymous function in the component class method.

const renderItem = ({item}) => (
  <NewsItem
    itemKey={item}
    onBodyPress={this.onItemBodyPress}
  />
);

However, such a solution will require us to change the component NewsItem.

class NewsItemComponent extends React.Component {
render() {
  ...
  return (
      ...
      <NewsItemBody
        ...
        onPress={() => this.props.onBodyPress(this.props.item)}
        ...
      />
      ...
  );
}

And again we return to the indicated problem - we pass a new lambda function to the child component for each render of the parent. Only now have we gone down a level. Log:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1

To get rid of this problem at the root, you can use the useCallback hook . It allows memoizing a function call with passing an argument. If the argument of the function does not change, then the result of the call useCallbackwill point to the same area of ​​memory. In our example, this means that when redrawing the same news, the prop onPresscomponent NewsItemBodywill not change. Hooks can only be used in functional components, so the final look of the component NewsItemwill be as follows:

function NewsItemComponent(props) {
  ...
  const {itemKey, onBodyPress} = props.item;
  const onPressBody = useCallback(() => onBodyPress(itemKey), [itemKey, onBodyPress]);
  return (
    <View>
      ...
      <NewsItemBody
        ...
        onPress={onPressBody}
        ...
      />
    </View>
  );
}

And the log:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_1
ITEM_TITLE_1

→ Full example code

Arrays and objects


In JavaScript, functions are represented as objects, along with arrays. Therefore, the example from the previous block is a special case of creating a new object in props. It is quite common, so I put it in a separate paragraph.

Any creation of new functions, arrays or objects in props leads to a component re-renderer. Consider this rule in the following example. Let's pass in a NewsItemBodycombined style of two values:

<NewsItemBody
  ...
  style={[styles.body, styles.item]}
  ...
/>

And again, the log shows the extra component renderers:

SCREEN
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1
--------------[ REFRESHING ]--------------
SCREEN
ITEM_3
ITEM_TITLE_3
ITEM_BODY_3
ITEM_2
ITEM_TITLE_2
ITEM_BODY_2
ITEM_1
ITEM_TITLE_1
ITEM_BODY_1

To solve this problem, you can select a separate style that will combine bodyand item, or, for example, move the declaration of the array [styles.body, styles.item]into a global variable.

→ Full example code

Array reducers


Consider another popular source of “brakes” associated with use FlatList. A classic application that contains a long list of items from the server implements pagination. That is, it loads a limited set of elements in the form of the first page, when the list of current elements ends, it loads the next page, and so on. An item list reducer might look like this:

const newsIdList = (state = [], action) => {
  if (action.type === 'GOT_NEWS') {
    return action.news.map(item => item.key);
  } else if (action.type === 'GOT_OLDER_NEWS') {
    return [...state, ...action.news.map(item => item.key)];
  }
  return state;
};

When each next page loads in the application’s style, a new array of identifiers is created. If we pass this array to props later FlatListon, here is what the component render logs will look like:

SCREEN
ITEM_<1..10>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..10>
ITEM_<1..20>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..20>
ITEM_<1..30>

For this example, I made a few changes in the test application.

  • Set the page size to 10 news.
  • item NewsItem FlatList-, connect. NewsItem React.Component .
  • .
  • . â„–1 .

The example shows that when each next page loads, all the old elements are rendered again, then the old elements and the elements of the new page are rendered again. For mathematics lovers: if the page size is equal X, then when the iith page is loaded, instead of rendering only Xnew elements, the elements are rendered (i - 1) * X + i * X.

“Ok,” you say, “I understand why all the elements are drawn after adding a new page: the reducer returned a new array, a new area of ​​memory, all that. But why do we need to render the old list before adding new elements? ” “Good question,” I will answer you. This is a consequence of working with the state of the component VirtualizedListon whose basisFlatList. I will not go into details, as they pull on a separate article. Who cares, I advise you to delve into the documentation and source.

How to get rid of such non-optimality? We rewrite the reducer so that he does not return a new array for each page, but adds elements to the existing one:

Attention! Antipattern!
. , , , PureComponent, . , . . Redux.

const newsIdList = (state = [], action) => {
  if (action.type === 'GOT_NEWS') {
    return action.news.map(item => item.key);
  } else if (action.type === 'GOT_OLDER_NEWS') {
    action.news.forEach(item => state.push(item.key));
    return state;
    // return [...state, ...action.news.map(item => item.key)];
  }
  return state;
};

After that, our log will look like this:

SCREEN
ITEM_<1..10>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..20>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<1..30>

We got rid of the rendering of old elements before adding elements to a new page, but old elements are still drawn after updating the list. The number of renderings for the next page is now equal i * X. The formula has become simpler, but we will not stop there. We only have Xnew elements, and we only want Xnew renders. We will use the already familiar tricks to remove news renders that have not changed props. Return connect to NewsItem:

SCREEN
ITEM_<1..10>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<11..20>
--------------[ LOADING NEXT PAGE ]--------------
SCREEN
ITEM_<21..30>

Fine! Now we can be satisfied with ourselves. There is nowhere to optimize.

→ Full example code

An attentive reader will indicate that after applying connect to the NewsItemlog, it will look like in the last example, no matter how you implement the reducer. And it will be right - if the news component checks its props before rendering, it does not matter if the old array is used by the reducer or it creates a new one. Only new elements are drawn and only once. However, changing the old array instead of creating a new one saves us from unnecessary renderings of the component FlatListused in it VirtualizedListand unnecessary iterations of props equivalence checks NewsItem. With a large number of elements, this also gives a performance increase.

Use mutable arrays and objects in reducers should be with extreme caution. In this example, this is justified, but if you have, say, normal PureComponent, then when you add elements to the mutable array, the components will not be rendered. Its props in fact remain unchanged, since before and after updating the array points to the same memory area. This can lead to unexpected consequences. No wonder the described example violates the principles of Redux .

And something else...


If you use presentation-level libraries, I advise you to make sure that you understand in detail how they are implemented. In our application, we use a component Swipeablefrom the library react-native-gesture-handler. It allows you to implement a block of additional actions when swiping a card from the list.

In code, it looks like this:

<Swipeable
  ...
  renderRightActions={this.renderRightActions}
  ...
>

Method renderRightActionsor renderLeftActionsreturns the component that is displayed after the swipe. We determined and changed the height of the panel during the change of components in order to fit the necessary content. This is a resource-intensive process, but if it occurs during the swipe animation, the user does not see interference.


The problem is that the component Swipeablecalls the method renderRightActionsat the time of rendering the main component. All calculations and even rendering of the action bar, which is not visible before the swipe, occur in advance. So, all these actions are performed for all cards in the list at the same time. This caused significant “brakes” when scrolling the board.

The problem was solved in the following way. If the action panel is drawn together with the main component, and not as a result of the swipe, then the method renderRightActionsreturns empty the Viewsize of the main component. Otherwise, we draw the panel of additional actions as before.
I give this example because supporting libraries do not always work as you expect. And if these are presentation-level libraries, then it is better to make sure that they are not wasting unnecessary resources.

findings


After eliminating the problems described in the article, we significantly accelerated the application on React Native. Now it is difficult to distinguish it in performance from a similar one, implemented natively. Excess renders slowed down both the loading of individual screens and the reaction to user actions. Most of all, it was noticeable on the lists, where dozens of components are drawn at once. We have not optimized everything, but the main screens of the application no longer slow down.

The main points of the article are briefly listed below.

  1. React Native : Props/State- .
  2. , React.PureComponent, , .
  3. , shouldComponentUpdate React.Memo .
  4. - . , (shallow compare). , .
  5. Supporting presentation-level libraries can lead to unexpected waste of resources. It is worth being careful in their application.

That's all. I hope you find the information useful. I will be glad to any feedback!

Useful sources


  1. Understanding Rendering in React + Redux
  2. Comparing Objects in JavaScript
  3. Improving Performance in React Functional Components using React.memo ()
  4. How Discord achieves native iOS performance with React Native

All Articles