Learn from this article how to improve the performance of a React Native app.
Good performance is crucial for any app or product. While working with React Native, you may often face problems with the performance of your app. That’s why you need to pay attention to the best practices and performance improvements for your React Native app during its development so that you can fix these issues and deliver a flawless experience to the end-users.
Let’s dive deep into the list of performance improvements and best practices you can utilize when developing your React Native apps!
Written by Sneh Pandya
So, let’s get started, but first, let us know what’s your relationship with CI/CD tools?
Provide Appropriate Navigation Strategies
The React Native team has been working for quite some time on addressing problems related to navigation and have fixed plenty of them, but there are still some issues that need to be fixed for the majority of apps to provide a seamless experience.
Difficult navigation between screens can prevent users from using your app at all. React Native offers four different ways to build navigation:
-
iOS Navigator: Can be used only for iOS and won’t help in Android navigation development.
-
Navigator: Can be used only for small applications and prototype development. Does not work well for complex or high-performance applications.
-
Navigation Experiment: Can be used in complex applications, but due to the complexity of its implementation, not everyone likes it.
-
React Navigation: Used by a large number of apps and often recommended. React Navigation is lightweight and can work for both small- and large-scale applications.
Avoid Use of ScrollView to Render Huge Lists
There are a few ways to display items with scrollable lists in React Native. Two common ways to implement lists in React Native are through the use of the ScrollView
and FlatList
components.
ScrollView
is simple to implement. It is often used to iterate over a list of a finite number of items, as shown below. However, it renders all children at once. This approach is good when the number of items in the list is quite low. On the other hand, using ScrollView
with a large amount of data can directly affect the overall performance of the React Native app.
<ScrollView>
{items.map(item => {
return <Item key={item.name.toString()} />;
})}
</ScrollView>
To handle large amounts of data in the list format, React Native provides FlatList
. The items in FlatList
are lazy loaded. Hence, the app does use an excessive or inconsistent amount of memory. FlatList
can be used as shown below:
<FlatList
data={elements}
keyExtractor={item => `${items.name}`}
renderItem={({ item }) => <Item key={item.name.toString()} />}
/>
Avoid Passing Inline Functions as Props
When passing a function as a property to a component, avoid passing that function inline, like below:
function MakeBeverage(props) {
return(
<Button title=' Make Beverage' onPress={props.onPress}/>
)
}
export default function BeverageMaker() {
return (
<View style={styles.container}>
<MakeBeverage
onPress={()=> console.log('making some beverage')}
/>
</View>
);
}
The above method isn’t recommended because any time the parent re-renders a new reference, the function is created again. This means that the child component re-renders even when the props didn’t change at all.
The solution is to declare the function as a class method or as a function inside a functional component so that the references remove any possibility of across re-renders.
export default function BeverageMaker() {
function handleMakeBeverage(){
console.log('Making beverage, please wait!')
}
return (
<View style={styles.container}>
<MakeBeverage
onPress={handleMakeBeverage}
/>
</View>
);
}
Scale and Resize Images
It is important to optimize images to improve your React Native app’s performance if the app is built to showcase a huge amount of graphical content or images. Rendering multiple images could lead to high memory usage on a device if the images are not appropriately optimized in terms of resolution and size. This may cause your app to crash due to memory overload.
Some improvements that can be applied to optimize images in a React Native app are:
- Using PNG format instead of JPG format
- Using smaller-resolution images
- Using WEBP format for images – this can help reduce the images' binary size on iOS and Android by nearly a third of the original size!
Cache Images
React Native offers Image as a core component. This component is used to display an image, but it does not have an out-of-the-box solution for issues like:
- Rendering a huge number of images on a single screen
- Low performance in general
- Low performance in cache loading
- Image flickering
However, these issues can be easily resolved by using third-party libraries, like react-native-fast-image. This library is available for both iOS and Android, and it works like a charm!
Avoid Updating state
or dispatch
Actions in componentWillUpdate
You should make use of the componentWillUpdate
lifecycle method to prepare for an update, not to trigger another one. If your aim is to set a state, you should do that using componentWillReceiveProps
instead. To be on the safe side, use componentDidUpdate
rather than componentWillReceiveProps
to dispatch any Redux actions.
Avoid Rendering Overhead and Unnecessary Renders
React Native handles the rendering of components in a similar way to React.js
. Therefore, the optimization techniques that are valid for React also apply to React Native apps. One optimization technique is to avoid unnecessary renders on the main thread. In functional components, this can be done by using React.memo()
.
React.memo()
is used to handle memoization, meaning if any component receives the same set of properties more than once, it will use the previously cached properties and render the JSX view returned by the functional component only once, saving rendering overhead.
A good example of this is shown below, where the Animal
component has a state variable called leg count
that is updated with a number associated with each pair whenever the button is pressed. When the button is pressed, the WildAnimal
component gets re-rendered, even though its text property does not change with each render. It is not doing anything special to its parent Animal
component and is just displaying text. This can be optimized by wrapping the contents of the WildAnimal
component with React.memo()
.
// Animal.js
const Animal = () => {
const [count, setCount] = useState(0);
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Button title='Press me' onPress={() => setCount(count + 2)} />
<WildAnimal text='text' />
</View>
);
};
// WildAnimals.js
const WildAnimal = React.Memo(({ text }) => {
return <Text>{text}</Text>;
});
Make Use of Hermes
Hermes is an open-source JavaScript engine optimized specifically for mobile applications. It is available for the Android platform for React Native version 0.60.4 and above. It is also available for iOS from version 0.64-rc.0 and above. Hermes helps reduce the download size of the APK, the memory footprint and consumption, and the time needed for the app to become interactive (TTI - Time to Interact).
To enable Hermes for Android, open build.gradle
and add the following line:
def enableHermes = project.ext.react.get("enableHermes", true);
To enable Hermes for iOS, open Podfile
and add the following line:
use_react_native!(:path => config[:reactNativePath], :hermes_enabled => true
Use nativeDriver With the Animated Library
One of the most popular ways to render animations in React Native apps is using the Animated library.
It uses nativeDriver
to send animations over the native bridge before the animation starts on the screen. This helps the animations to be executed independent of blocked JavaScript threads, thus resulting in a smoother and richer experience without flickering or dropping many frames. To use nativeDriver
with Animated
, you can set its value to true
, as shown below:
<ScrollView
showsVerticalScrollIndicator={ false }
scrollEventThrottle={ 1 }
onScroll={Animated.event(
[{ nativeEvent: { contentOffset: { y: animatedValue } } }],
{ useNativeDriver: false }
)}
>
</ScrollView>
Avoid Arrow Functions
Arrow functions are a common culprit for wasteful re-renders. Don’t use arrow functions as callbacks in your functions to render views. With the arrow function, each render generates a new instance of that particular function, so when reconciliation happens, React Native compares a difference, and because the function reference doesn’t match, it is unable to reuse old references.
class ArrowClass extends React.Component {
// ...
addTodo() {
// ...
}
render() {
return (
<View>
<TouchableHighlight onPress={() => this.addTodo()} />
</View>
);
}
}
class CorrectClass extends React.Component {
// ...
addTodo = () => {
// ...
}
render() {
return (
<View>
<TouchableHighlight onPress={this.addTodo} />
</View>
);
}
}
Avoid Excessive Use of Higher-Order Components
When your application becomes complex and you want to share common patterns across your components, it is common to use higher-order components. Using higher-order components is, in fact, a good practice, even if it can sometimes be arguable, as it increases indirection. However, it can increase the complexity of your code.
What you should really be careful about is not instantiating a higher-order component on the fly, specifically during a render method. This is because this pattern effectively creates new components on its own.
React Native does not know (and does not identify) that this is effectively the same component as before. This puts lots of pressure on the reconciliation algorithms. More than that, it also forces React Native to run all the lifecycle methods.
Avoid Implementing Bulk Reducers
If you are not using Redux with normalizr
and/or rematch
or you are writing reducers yourself at some point, be careful to always mutate only the objects that you need to. When re-fetching a list of items from the network and saving it in the reducer, the naïve approach looks like this:
function customReducer (state = {}, action) {
switch (action.type) {
case UPDATE_ITEMS_DOCS:
return { ...state, ...action.docs }
}
}
return state
}
What you really need to do is update your store only when needed. More precisely, only update references that need to be updated. If an item already has the same value as before, then you don’t need to save a new reference for it in Redux. Updating references in your store will create useless renders that will produce the same components again and again.
Move Views With Caution
Moving views on the screen with scrolling, translating and rotating drops UI thread FPS. This is especially true when you have text with a transparent background positioned on top of an image or in any other situation where alpha composition is used to re-draw the views on each frame. You will find that enabling shouldRasterizeIOS
or renderToHardwareTextureAndroid
can help improve performance significantly.
Be careful not to overuse this, or your memory usage could get exponentially higher. Profile your performance and memory usage while using these properties. If you don’t plan to move any views anymore, turn this property off.
Use Style References Correctly
If you use objects or arrays for styling, they will create new instances with each new render. You should use a StyleSheet
in React Native, which always passes a reference instead of a new object or array creation and allocation.
class StylingClass extends React.PureComponent {
render() {
const style = {width: 10, height: 10}
return (
<View style={{flex: 1}}>
<View style={[style, {backgroundColor: 'red'}]}/>
</View>
);
}
}
import {StyleSheet} from 'react-native';
class CorrectClass extends React.PureComponent {
render() {
return (
<View style={style.container}/>
);
}
}
StyleSheet.create({
container: {
flex: 1
}
});
Make Use of InteractionManager
InteractionManager allows you to schedule the execution of tasks on the JavaScript thread after any interactions or animations have been completed. In particular, InteractionManager
allows JavaScript animations to run smoothly.
Tasks can be scheduled to run after interactions using the following code:
InteractionManager.runAfterInteractions(() => {
// ...long-running synchronous task...
});
Avoid Anonymous Functions While Rendering
Creating functions in render()
is a bad practice that can lead to some serious performance issues. Every time a component re-renders, a different callback is created. This might not be an issue for simple and smaller components, but it is a big issue for PureComponents
and React.memo()
or when the function is passed as a prop to a child component, which would result in unnecessary re-renders.
Remove Console Statements
Using console.log
statements is one of the most common patterns to debug in JavaScript applications in general, including React Native apps. Leaving the console statements in the source code when publishing React Native apps can cause some big bottlenecks in the JavaScript thread.
One way to automatically keep track of console statements and remove them is to use a third-party dependency called babel-plugin-transform-remove-console
. You can install the dependency by running the following command in a terminal window:
npm install babel-plugin-transform-remove-console
# OR
yarn add babel-plugin-transform-remove-console
Once installed, modify the .babelrc
file to remove the console statements, as shown below:
{
"env": {
"production": {
"plugins": ["transform-remove-console"]
}
}
}
Enable the RAM Format
Using the RAM format on iOS will create a single indexed file that React Native will load one module at a time. On Android, it will create a set of files for each module by default. You can force Android to create a single file, like iOS, but using multiple files can be more performant and requires less memory on Android.
Enable the RAM format in Xcode by editing the build phase “Bundle React Native code and images”. Before
../node_modules/react-native/scripts/react-native-xcode.sh
add
export BUNDLE_COMMAND="ram-bundle":
export BUNDLE_COMMAND="ram-bundle"
export NODE_BINARY=node
../node_modules/react-native/scripts/react-native-xcode.sh
On Android, enable the RAM format by editing your android/app/build.gradle
file. Before the line apply from: "../../node_modules/react-native/react.gradle"
, add or amend the project.ext.react
block:
project.ext.react = [
bundleCommand: "ram-bundle",
]
Use the following lines on Android if you want to use a single indexed file:
project.ext.react = [
bundleCommand: "ram-bundle",
extraPackagerArgs: ["--indexed-ram-bundle"]
]
Note: If you are using the Hermes JS Engine, you do not need RAM bundles. When loading the bytecode, mmap ensures that the entire file is not loaded.
Make Use of Memory Optimization
Native applications have a lot of processes running in the background. You can find the unnecessary ones with the help of Xcode to improve performance. In Android Studio, there is an Android Device Monitor, which is used to monitor leaks in applications. Using scrolling lists like FlatListSectionList
or isVirtualList
is a sure way to increase performance.
You can use the following code to monitor performance and identify memory leaks:
Import PerfMonitor from ‘react-native/libraries/Performance/RCTRenderingPerf’;
perfMonitor.toggle();
PerfMonitor.start();
SetTimeout ( () => {
perfMonitor.stop();
}; 2000);
}; 5000);
Make Use of Uncontrolled Inputs
Contrary to best practices in React, where most of the input fields are controlled inputs, it is more performant to use uncontrolled inputs in React Native.
The problem with controlled inputs in React Native is that on slower devices or when a user is typing really fast, rendering glitches may occur while updating the view. Controlled inputs have to cross the RN-bridge because it deals with both the native and JavaScript thread.
Due to the known performance limitations of the RN-bridge, using uncontrolled inputs is the most accessible approach. This is as simple as removing the value property. Also, you don’t have to deal with re-renders because there are no state changes when the uncontrolled inputs are modified.
export default function UncontrolledInputs() {
const [text, onTextChange] = React.useState('Controlled inputs');
return (
<TextInput
style={{ borderWidth: 1, height: 100, borderColor: 'blue' }}
onChangeText={text => onTextChange(text)}
defaultValue={text}
/>
);
}
Optimize Android App Size
At the beginning of each React Native project, you usually don’t care about the application size. After all, it is hard to make such predictions so early in the process. But it takes only a few additional dependencies for the application to grow from the standard 5 MB to 10, 20 or even 50, depending on the codebase.
By default, a React Native application on Android consists of:
-
Four sets of binaries compiled for different CPU architectures
-
A directory with resources, such as images, fonts, etc.
-
A JavaScript bundle with business logic and your React components
-
Other files
Set the boolean flag enableProguardInReleaseBuilds
to true
, adjust the ProGuard rules to your needs and test release builds for crashes.
Also, set enableSeparateBuildPerCPUArchitecture
to true
.
You should also optimize the distribution process by taking advantage of Android App Bundles when releasing a production version of your app.
Debug Faster with Flipper
Debugging is one of the most challenging tasks of every developer’s work. It is easier to introduce a new feature when everything seems to be working, but finding what went wrong can be very frustrating.
Time is an important factor in the debugging process, and we usually have to solve the issues quickly. However, debugging in React Native is not very straightforward, as the issue you are trying to solve can occur on different levels. Namely, it may be caused by:
-
JavaScript: your application’s code or React Native, or
-
Native code: third-party libraries or React Native itself
Flipper is a debugging platform for mobile apps. It also has extensive support for React Native.
Use Firebase Performance Monitoring
Firebase Performance Monitoring is a service provided by Google that helps you to gain insights into the performance characteristics of your app. You should use the Firebase Performance Monitoring SDK for React Native to collect performance statistics and data from your apps, then review and analyze that data in the Firebase console.
Conclusion
React Native is an open-source framework for creating cross-platform mobile applications. JavaScript is at its core, and it has components for building interfaces and functionalities. It is a popular high-performance framework that delivers a seamless experience at scale as long as you build apps with performance in mind from the start.
Useful Links and Resources
-
Here’s an article on why Android developers should pay attention to React Native in 2021.
-
Here’s an article on choosing the right database for your React Native app.
-
Here’s a practical guide on Continuous Integration and Delivery for React Native apps.
-
Here’s a practical guide on React Native + Firebase + Codemagic for iOS.
-
Here’s a practical guide on React Native + Firebase + Codemagic for Android.
-
For discussions, learning and support, join the Codemagic Slack Community.
Sneh is a Senior Product Manager based in Baroda. He is a community organizer at Google Developers Group and co-host of NinjaTalks podcast. His passion for building meaningful products inspires him to write blogs, speak at conferences and mentor different talents. You can reach out to him over Twitter (@SnehPandya18) or via email (sneh.pandya1@gmail.com).