I have given a full talk on this topic in case you wanna check out
So before beginning with a performance in react native, let's understand how react native is handling our UI and business logic so that we can understand the whole system better, now react native has 3 threads.
- JS thread - where our UI is declared in JSX and where our business logic resides.
- Shadow thread - Where shadow nodes for layouts are created.
- Platform thread (consider this as platform side popularly known as a native thread) - where UI is painted and native events like touch and scroll are recognized and handled.
Communication between these threads is asynchronous and done by javascript bridge, creation of views, responding to events everything needs to be passed by javascript bridge. example javascript thread will send message to native thread for creation of new view and it will map all your style properties like this.
public void setOpacity(@NonNull T view, float opacity) {
view.setAlpha(opacity);
}
So how does the exact rendering is happening? our goes to traditional React.createElement(View,...) and if we console.log it we can see large object. now on the javascript side "react" resides and it will create it fancy virtual DOM for reconciliation and as soon as it recognizes to update or create a new node, it will send that node to shadow thread.
As native platforms don't understand flex, react native uses yoga layout engine to compute the view positions, now the shadow thread contains shadow nodes that are used primarily for layouting therefore it extends {YogaNode} to allow that, now native platform will pick this node and paint it.
So as our UI is painted through native side, how native events like touch and scroll are handled? because the native event ( i.e touch or scroll ) is identified at the native side but our responder function is on the javascript side, everything sounds like magic? yes, it is.
view.setOnClickListener(
new View.OnClickListener() {
@Override
public void onClick(View v) {
final EventDispatcher mEventDispatcher =
UIManagerHelper.getEventDispatcherForReactTag(
(ReactContext) view.getContext(), view.getId());
if (mEventDispatcher == null) {
return;
}
mEventDispatcher.dispatchEvent(
new ViewGroupClickEvent(
UIManagerHelper.getSurfaceId(view.getContext()), view.getId()));
}
});
Now there are gonna be people like me who don't understand native at all but we can use basic programming knowledge to understand what's going on, whenever the native side recognizes a touch it needs to execute the function associated with it but that function resides on the javascript side, so that's why it will dispatch an action with the help of reactTag (consider this as identifier which helps both native & javascript side to recognize and respond), on javascript side it will receive the action and execute the associated function.
Coming To Handling Performance
So performance in react native apps are primarily compromised because of javascript bridge as its used to communicate between native side and javascript side, sometimes when there are too many operations going around our javascript bridge is choked and the best way to look at the situation is like this.
So when the Suez canal was blocked did the trade stopped ? no. Trading was disturbed, ships either had to wait or take another route, same what happens in javascript bridge world sometimes bridge gets choked because of too many operations, but sadly we don't have any other route to travel, so all the pending operation needs to wait till the route ( i.e js bridge ) is clear.
The key to making your app perform better is by taking care of the famous react-native bridge and ensure that the bridge is as free as possible. now you might think that every library you choose should be made with native code so all heavy operations are executed on the native side but that's not true.
when choosing the library you should draw a line of when to pick native solution/javascript solution, when needed use dedicated libraries for native behavior such as navigation, animations, and calendar, native components such as accordion or checkbox doesn't make that much sense.
1) Navigation Library
For navigation, we have 2 popular libraries which are React Native Navigation it offers us complete native navigation, which is better in terms of performance and gives a proper native look and feel.
whereas React Navigation provides us 2 API
createStackNavigator - It is made over community libraries and it mimics the native behavior of navigation, we can consider this as a javascript solution for navigation, the biggest benefit here is that your application can have the same navigation look and feel over the platforms but at the same time performance is compromised.
createNativeStackNavigator - It offers us native navigation just like RNN, and it's made over react-native-screens
let's compare the performance of createStackNavigator, createNativeStackNavigator and React Native Navigation, so am going to show you how much difference there is in performance and show you bridge messages with flipper.
createStackNavigator
As you can see from flipper messages our navigation is handled by the javascript side and for most of the part our animations are handled with vanilla animated API.
createNativeStackNavigator
You can notice the performance benefits we are having here, and as you can see our navigation is getting handled natively.
React Native Navigation
As you can see React Native Navigation also handles our navigation on the native side from flipper messages, and in terms of performance React Native Navigation and createNativeStackNavigator both are giving us similar results, the only big difference is that RNN also offers us native shared element transition.
github repo which contains all navigations API with flipper bridge spy.
2) Animation Library
For animations, react-native provides us animated API which does all layout computations for every single frame on the javascript side and send it over to native via bridge and as you can guess this is way too heavy, but it also provides us the option to use useNativeDriver which ensure all calculations for interpolation or layout happens on the native side rather than on the javascript side, but in the end, animated API communicates via the JS bridge, as I explained earlier how touches are recognized in native side and it's associated function gets executed on the javascript side, now imagine a scenario where API calls are going around and you are scrolling a list with animated collapsible header chances of getting your bridge choked are high and when you tap on button it might not even respond.
To understand reanimated 2 let's go back to how react native is working, as I talked about platform thread earlier as we know that javascript is single-threaded but native platforms aren't, so like in native android we can have multiple threads one thread is only responsible for handling UI, other to recognize touch event serialize to JSON and send over the bridge. now as you can see our UI thread is pretty much free, reanimated 2 leverage this it will provide us worklets which are tiny chunks of JavaScript code that can be moved to a separate JavaScript VM and executed synchronously on the UI thread. You might be thinking what if there is a need to execute some side effects on animation but our animations now are running on a separate thread, so don't worry we got runOnJs() out of the box. The new re-animated API also expands new possibilities my personal favorite is react-native-multithreading.
Now let's check the comparison of vanilla animated API with/without native driver and reanimated 2, with flipper to see all bridge messages.
Animated API Without Native Driver
Now as you can see without useNativeDriver, all calculations and everything is done on the javascript side and updated using setNativeProps, so there is too much use of javascript bridge here which clearly means that we want to avoid this for sure.
Animated API With Native Driver
Now as you can see with useNativeDriver our animation gets initiated at JS and the rest all are handled on the native side, this is all good if we have a limited number of animations going around and it won't harm us that much.
Reanimated 2
As you can there is no involvement of bridge, which gives us the ability to run our animation concurrent with heavy operations without worrying over jank, I also maintain one repo to practice and showcase basic animations with the reanimated 2 link here.
3) Handling Big Lists
when you are dealing with big lists and we know that our maximum list items are going to have the same viewports then we should opt for recylerListView instead of flatList or virtualizedList, because both flatList and virtualizedList are JS based virtualized solutions and recylerListView takes a different approach of solving big list explained beautifully here, but also it depends on different use-cases if we are making something like chat UI recylerListView won't be ideal fit because every chat message will have different viewports so FlatList or VirtualizedList might be better solution there. apart from handling the virtualization, there are other things to which can boost our performance like cache images using the most popular lib react-native-fast-image and using memo for rendering child components also adds a huge difference.
Also, recylerListView gives us the ability to create an amazing layout behind the scenes everything is position absolute aligned, and just like flatlist it's made over scrollview, another thing is that though its API is a little hard to understand but you can have so much control over your layout best explained here.
I managed to create this amazing layout using recylerListView, now as you can see we have images in grid along with nested recylerListView this type of layout is very common in e-commerce app but unfortunately we can't create something similar with flatlist but with recylerListView we got saw much control over our layout.
RecylerListView demo link here and github repo here.
4) Memo,useMemo and useCallBack
So as I explained earlier that our business logic resides in the JS thread and in order to execute js, we need an engine that might be JSC or Hermes, now whenever there are unconditional re-renders happens, those views aren't recreated from the native side but a lot of memory is wasted because of diffing algo and anonymous function, {arr.length} type methods (inside render) and console.log are actually all javascript engine task and already it's doing a lot of work in react native realm so memo and useCallBack can help us here, Like in this POC I added 460-480 items to flatlist in 90 seconds. the bridge was choked either way but with memo render time and bridge free time had a significant difference, one of the best memo guide here.
With memo
Without Memo
5) Use Hermes Engine
Hermes engine improves android performance a lot because JSC existed for IOS devices because of safari but for android, it was specially built and bundled that's why IOS has better performance. enabling Hermes will result in improved start-up time, decreased memory usage, and smaller app size and now IOS support is also out and Hermes is like no brainer, you should definitely opt-in for this.
Takeaways
- It's better to understand the internals first, so you can take an application decision accordingly.
- Not every library you choose should be made with native code.
- If your application is small and got 5-6 screens, createStackNavigator won't be a trouble.
- If your application is big and needs attention to performance opt for either createNativeStackNavigator or React Native Navigation.
- Don't ever use vanilla animated API without useNativeDriver.
- For animations try to use reanimated 2 more so UI can run synchronously without any doubt.
- When handling a big list if your components got similar viewports use recylerListView instead of flatList.
- Cache your images with react-native-fast-image.
- Always memo your child components to skip unconditional re-renders.
- Use Hermes engine.