How to boost the performance of React Native apps?

React Native surely entered the mobile development world with a bang. Released in 2013, it has soon grown to be one of the most popular cross-platform frameworks. Its numerous advantages, such as time and cost efficiency or strong community support, have led to React Native becoming the core of many world-renowned apps, such as Facebook, Tesla, or Uber. But that’s not only the industry giants we’re talking about – React Native does well in many other scenarios. Being a startup or an Agile company you should be able to enjoy its benefits as well.

📚 Here are four cases when you should create a React Native app.

The thing is, however, that simply choosing React Native does not guarantee success. Regardless of all its pros, the framework (much like any other solution) has some pitfalls that can slow down your application – especially if you’re dealing with complex architecture or deeply nested components.

To shed more light on this matter and prevent you from failing miserably, I’ll cover some of the most common cases of and reasons for React Native apps performance issues. The article will be divided into two main sections: one devoted to pure React and one that concerns the more native side. Before I take you on this adventure, remember about one thing: these solutions should be used only when you (or your users) notice that app performance is low. Utilizing these methods prematurely may cause more harm than good as each of them has some downsides and requires special attention.

React Native is still React

Before we dive into things related strictly to React Native, we need to remember that most of the optimization techniques used on the web can be transferred to React Native. After all, it is still React. Here are some of the most common ones.

Avoid unnecessary renders: React.memo()

React.memo() handles memoization of functional components, meaning that if the component will receive the same props twice, it will use previously cached props and run the render() function only once. It is the same method as React.PureComponent for class components. Take this super simple (and a little bit contrived) code for example:

const ParentComponent = () => {
const [count, setCount] = useState(0);

return (
<View>
<TouchableOpacity onPress={() => setCount(count + 1)}>
<Text>Press me!</Text>
</TouchableOpacity>
<Text>You pressed {count} times.</Text>
<ChildComponent text="Some placeholder text." />
</View>
);
};const ChildComponent = ({ text }) => {
return <Text>{text}</Text>;
};

ParentComponent has a local state of count that gets updated on button press. Right now, each time you press the button, ChildComponent gets rerendered, even though the text prop doesn't change. We can remedy that simply by wrapping ChildComponent in React.memo():

const ChildComponent = React.memo(({ text }) => {
return <Text>{text}</Text>;
});

ChildComponent does nothing special here, but you can easily imagine that there could be some heavy computation going on, which would make this optimization more sensible.

Keep in mind that when passing a function as a prop, memoization will break if you don’t use useCallback() hook. This hook makes sure that you are passing the same instance of callback function for a given set of arguments. If you were to pass callback directly, ChildComponent would get a new instance of callback function and it would not pass equality check.

React.memo() and useCallback() should be used cautiously, however, as they may be risky in some cases. Since I’m just scratching the surface of this topic, please refer to sources to get a more detailed view of the problem.

React.memo()vs React.useMemo()

Just to clarify possible confusion: React.memo() is a higher-order component to help you deal with functional components memoization. useMemo() on the other hand, is a hook letting you memoize a single value, whose computation is expensive. I will shamelessly copy code from official react docs:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

Second argument to useMemo() is an array of dependencies – only when these changes does the value get recomputed.

Optimizing Redux

Redux is not an integral part of React itself, but it is so commonly used that it would simply be a shame not to mention it.

Selectors

Let’s stick to the topic of memoization for a while longer and talk about selectors. Selectors are functions that take a redux state as an argument and return a piece of that state. They can simply return a string or a number derived from the state or they can be used to do some heavier work. As you might have already guessed, we are interested in the latter case.

Using the same concept as described before, we can minimize the number of recomputations and rerenders by utilizing memoized selectors. Reselect is a go-to library for that. It has simple API which works more or less like this:

import { createSelector } from 'reselect';

const getFirstName = (state) => state.firstName;
const getLastName = (state) => state.lastName;

export const getFullName = createSelector(
[getFirstName, getLastName],
// imagine this function does really expensive computing:
(firstName, lastName) => `${firstName} ${lastName}`
);

This code snippet continues the journey of contrived examples, but with a little help of imagination, we can visualize it being a part of a real-life scenario. We use two, non-memoized selectors to get the data we want: first name and last name. We do not transform data in those selectors, so no need for them to be memoized. By utilizing createSelector we create the getFullName selector, which will recompute only when firstName or lastName changes.

Rendering collections of data

Another possible thing that can cause performance hiccups is rendering data held in arrays. Usually, the easiest way to make it happen in Redux is to connect a parent component and map all the items inside it. This is a great moment to finally use a todo list example:

const TodoList = ({ todos, toggleTodo }) => {
// ...
return todos.map((todo) => {
return <Todo todo={todo} toggleTodo={toggleTodo} />;
});
};const Todo = ({ todo, toggleTodo }) => {
return (
<View>
<Checkbox checked={todo.isDone} onPress={toggleTodo} />
<Text>{todo.name}</Text>
</View>
);
}

Where todos are represented as an array of objects in redux store:

// ...
todos: [
{
id: 1,
name: 'Todo 1',
isDone: false,
},
// ...
];

There is nothing wrong with that approach when there are not many items to render or the item renders are light. The problem starts with bigger or complex sets of data. Using that method, when the user clicks Checkbox the reducer returns a new array reference causing all the items to rerender, even though only one changed.

If we change how we shape our state to this:

// ...
todos: {
"1": {
id: 1,
name: 'Todo 1',
isDone: false,
},
// ...
};

We can iterate through our collection of todos and instead of passing todo object we can pass id of given todo:

const TodoList = ({ todos, toggleTodo }) => {
// ...
return Object.keys(todos).map((todoId) => {
return <Todo todoId={todoId} />;
});
};

Passing only todoId to <TodoItem /> forces us to connect <TodoItem /> to the store:

const Todo = ({ todo, toggleTodo }) => {
return (
<View>
<Checkbox checked={todo.isDone} onPress={toggleTodo}/>
<Text>{todo.name}</Text>
</View>
);
}

const mapStateToProps = (state, ownProps) => ({
todo: getTodoById(state, ownProps.todoId)
});

// ...

getTodoById could be simply:

const getTodoById = (state, id) => state.todos[id];

Now we can update a single todo without rerendering the whole list as we hold references to each individual todo rather than the whole todo array.

This example is really simple as the data structure is flat. You won’t probably need to optimize code with that level of complexity. Also, I would not recommend writing all of the lists like that from the start. Do it when you really need to.

Another thing to note is that with complex and deeply nested data it is recommended to use a third party library that would transform (or normalize) state shape for you, like normalizr.

Why did you render

There is a tool to help discover the reasons for rerenders and help you avoid them: why-did-you-render. As the name suggests, it logs to the console the reason why a given component was rerendered. Let’s take a look at our first example, the one with React.memo(). If we hook up why-did-you-render to <ChildComponent /> (before using memo!), we will get the following result:

React Native Performance: why did you render

It actually tells you that it would be a good idea to make <ChildComponent /> pure: exactly what we did using React.memo(). Pretty cool, isn’t it?

Native configurations optimization

So far, I’ve been focusing on the React side of performance in an attempt to highlight a few common themes that you may wish to pay attention to when having React Native performance issues. Now, let’s move on to issues connected directly to the React Native platform. We can start with things that won’t really require a lot of work (hopefully) but may give your app an instant boost.

Test for performance in release mode

One thing worth mentioning is that you should always test React Native performance when running the release mode of your app. Communicating warnings and error messages requires a lot of work to be done at runtime which can mislead you into thinking that your app’s performance is poor. Before taking any steps towards performance optimization, it is worth creating and testing the release build of your app.

📖 Wondering whether React Native is a good choice for startups? Read our article on that topic to find out!


Keep you React Native version up to date

It may seem like really obvious advice but there are a couple of good reasons to do that. React Native uses the JavaScriptCore engine to run JavaScript code. As it is also used by Safari web browser, the iOS version gets updates whenever the system is updated. Android, on the other hand, does not and the React Native team had to manually bundle it. Because of that, the Android version of JavaScriptCore was not updated for a couple of years until version 0.59.0. And a couple of years in the JavaScript world is like an era. So by only updating your app, can you get a performance boost for Android.

From version 0.60.4 of React Native, you can ditch JavaScriptCore entirely and use an engine called Hermes. It decreases memory usage, reduces app size, and improves start-up time. This feature is available only for Android. Adding it is as simple as changing a flag in build.gradle:

def enableHermes = project.ext.react.get("enableHermes", true);

Of course, there are a number of other tweaks, optimizations, and features coming with every new version of React Native so it is always a good idea to keep your app up to date. React Native team has made it quite easy to upgrade from one version to another. There are two ways you can do that. One is through CLI command:

npx react-native upgrade

Or, if you’re more into the manual approach, you can use React Native Upgrade Helper which shows diffs between two projects created with react-native init command for given versions. You can read more about upgrading to new versions of React Native in the official docs.

Flipper

From React Native version 0.62.0, Flipper works out of the box. It is a debugging platform for iOS, Android, and React Native. It integrates directly with native code and doesn’t require ‘Remote Debugging’ which means no runtime differences between JS engines. It shows logs, has a network inspector and React Devtools built-in. Flipper also allows you to tap into native Android and iOS logs whereas normally, you would have to use XCode and Android Studio for that. It also boasts plugin support which you can install directly from the desktop app.

Remove console.log()

console.log statements are frequently used for debugging in React Native. There are tools like redux-logger which depend on that method entirely. As stated in the official docs, “these statements can cause a big bottleneck in the JavaScript thread”.

Following advice found in docs, we can use a babel plugin to remove all the console.* calls from the app. You need to first install it:

npm i babel-plugin-transform-remove-console --save-dev

And then change .babelrc file to remove all console calls from production env:

{
"env": {
"production": {
"plugins": ["transform-remove-console"]
}
}
}

Optimizing lists

Lists are bread and butter of mobile apps development. I can’t think of any projects without using them at some point. They are also common reasons for performance issues, so let’s focus on their implementation in React Native.

Use <FlatList /> instead of <ScrollView>

On the most basic level, a list can be implemented with <ScrollView /> and a map function. It can work well for many situations where the amount of elements to render is limited. The minimal example would look like this:

<ScrollView>
{items.map(item => {
return <ListItem key={item.id}/>;
})}
</ScrollView>

The main drawback of this approach is that <ScrollView /> will render all the children at once which can affect the performance dramatically when dealing with large datasets.

<FlatList /> is a special component that was made to display a large number of items. <FlatList /> ensures that children are lazy-loaded which basically guarantees that the app consumes a constant amount of memory regardless of the number of rows to display at once. Simply by refactoring our code to use <FlatList /> we gain React Native performance boost:

const renderItem = ({item}) => {
return <ListItem name={item.name}/>;
}

return (
<FlatList
data={elements}
renderItem={renderItem}
keyExtractor={(item) => `${items.id}`}
/>
);

<FlatList /> also has a number of additional props aimed to handle most of the common cases when working with lists: displaying headers, footers, pull to refresh etc. which also makes things a lot easier. Head over to official documentation to learn more.

Optimize <FlatList />

When default values held in <FlatList /> inner config are not enough to make views performant, there’s a number of other props we can tweak to decrease memory consumption of <FlatList />. Here is a list of them with a short description:

Prop name Description Default value removeClippedSubviews views not in viewport are detached from view native view hierarchy false maxToRenderPerBatch amount of items rendered per batch – new group of items rendered on scroll 10 updateCellsBatchingPeriod delay in milliseconds between batch renders 50 initialNumToRender number of items listed during first render 10 windowSize number of windows (1 = viewport height) to render simultaneously - default is 21 which means 10 below, 10 above and 1 in the middle 21

React Native team did a superb job describing each of these props in the docs. There is even a special section dedicated to <FlatList /> performance. All of these props have some caveats and their usage may result in bugs. Please, do read the docs carefully before applying them.

There are also a couple of rules that you can stick to in you want to keep <FlatList /> performant:

  • Use light and simple components for list items – minimize the amount of logic in items, avoid using nested and complex components, instead of using big images, crop the pictures to make them as small as possible.
  • Avoid unnecessary renders – everything I wrote in the first section of this article applies but you might want to fine-tune React.memo() comparison function (the second argument to React.memo()) to get really granular control of when <FlatList /> renders.
  • Optimize images – utilize a library like react-native-fast-image; the more quickly the images load, the faster the <FlatList /> will become responsive.
  • Using getItemLayout – for fixed items size, using this function can greatly improve performance as it allows to skip layout measurement. Example from docs:

const getItemLayout = (data, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
});

For items whose size is more dynamic, calculating height can be tricky but sometimes it is worth a try. There is a neat library that can help with calculating components’ height in the cases where it depends on the amount of text: react-native-text-size.

  • Always use keyExtractor or key – keys help to identify which items changed, were deleted, or added. Not using them can negatively affect performance.
  • Extract renderItem to a variable – move renderItem function to separate variable to prevent it from being recreated on every render.

Optimizing start-up time

Because React Native apps depend on JavaScript, during the start-up of your app, JavaScript code needs to be loaded in the memory. There, it can be handled by the JavaScript engine (JavaScriptCore or Hermes). The size of JS bundle impacts the amount of time your app needs to become responsive. There is even a special term for this process: time to interactive (TTI). React Native uses Metro bundler by default and unfortunately it does not have a tree shaking feature, which means that all of the imported libraries are bundled and loaded in the memory.

That’s why it is important to take a close look at the libraries you have and see if you let them realize their full potential. A common example for a library that is heavy but mostly used for only a few of its features is moment.js. It has 67,9kB bundle size while day.js has only 2kB.

Some libraries allow you to import only parts of their code that you are actually using - eg. lodash or date-fns. Example from date-fns docs:

import { format } from 'date-fns'

format(new Date(), "'Today is a' iiii")
//=> "Today is a Tuesday"

Measuring React Native app's performance

To check if our optimizations work, we need to somehow measure how applications perform before and after any changes we make. It can be tricky because React Native consists of two domains: JavaScript and Native. As there are few options to check performance, let's start with the most basic one.

💰 Before you proceed to uncover the best ways to measure the app’s performance, you might be interested to find out how React Native saves time and money during software development.

 

Perf monitor

It is a simple, high-level overview of how much memory an app uses, how many views are there on screen, and what the framerates' values are. It can be enabled from the developer menu.

Chrome profiler

This method won’t be really precise because it uses remote debugging: in this case, JavaScript code will be sent to Chrome and executed by the Chrome engine. However, it may help to pinpoint bottlenecks and give you a more detailed overview of what might be wrong. To use it, enable remote debugging, go to the Performance tab in Chrome, and run the profiler. After choosing a debugger worker you should get a flame chart.

Native solutions

React Native docs also point towards using software strictly created for iOS and Android platforms. I feel that describing how to use these tools would call for a separate article. If you want to learn more, please refer to official docs.

React Developer Tools

It is also possible to measure the performance of a react Native app in React Dev Tools. It works only on react-devtools stand-alone version. You could also use it from debugging apps like React Native Debugger or Flipper.

Flipper

Flipper has some plugins which can help with measuring performance. It also has React Devtools support right from the start. Definitely worth checking out.

Final thoughts on boosting the performance of React Native apps

Along with all the benefits of React Native come some limitations which may have a negative influence on your app’s performance. The good news is, they don’t have to – following a couple of best practices and using the recommended tools mentioned above, you can rest assured that the speed of your product will be satisfactory to its users.

Recommended sources

Looking to make your app work even better? See more tips on effective software modernization!

Navigate the changing IT landscape

Some highlighted content that we want to draw attention to to link to our other resources. It usually contains a link .