React Native Performance Learnings
In mobile apps, performance is critical for a good user experience. At Tableau we have been using React Native for three years now, and we have learned a lot about getting good native-like performance with it. For the most part it doesn’t take much effort, as long as you have an understanding for how things work, follow some good patterns, and avoid some pitfalls. In this article we share the most important things we have learned.
Understand React Native Performance Basics
React Native maintains a good page on performance with a wealth of information. Make sure you are somewhat familiar with all of it, though not everything applies to every project (we have never had to enable RAM bundles or use inline requires for example).
A couple things that are mentioned on that page are worth emphasizing here. First performance problems should be checked on a real device with a release build. Especially on Android. The difference between the Android emulator and a real device, and between a debug and release build, can be enormous. Secondly, if you are doing any kind of animation, make sure to enable useNativeDriver in the animation. It can make a huge difference.
Understand Reconciliation
The most common performance problem we have had is when the JavaScript side of the app is bogged down while the native side isn’t. This means that native UI elements are responsive (scrolling is fine, tap animations are fine, etc) but the app otherwise feels unresponsive or laggy. This is because the JavaScript thread is too busy processing something and not responding to user interactions.
When we have hit this it has always been related to rendering some expensive React component, or a ton of simple React components, too often and unnecessarily.
React also has a good page about performance that is worth absorbing. A lot of the page isn’t all that relevant to React Native, but the parts on Avoiding Reconciliation and the React Profiler are (more on the profiler below).
Briefly, reconciliation is the process React uses to decide how to update the underlying DOM (React web) or native UI elements (React Native) based on what is rendered by your JavaScript components. Read the React documentation on reconciliation for more details.
In React Native we have found that we really benefit from avoiding reconciliation, more-so than in web apps. Avoiding reconciliation translates to the JavaScript thread spending much less time on rendering, freeing it up to be responsive to user interactions.
Use PureComponent (or React.memo)
To avoid unnecessary rendering and reconciliation we made it a rule that all React components we write should derive from PureComponent
. We currently only use class-style components, so if you are writing functional components and using hooks you can use React.memo to get the same behavior.
All PureComponent
does is prevent rendering in cases where the props or state haven't changed. It detects changes via a simple shallow equality comparison: iterating over all the keys of the old and new props or state objects, and then doing a strict equality comparison on each pair of values. If there is a change, the component is re-rendered, but if there is no change it won’t be re-rendered and (importantly) neither will all its children.
You will find advice out there suggesting that you should be more nuanced in your use of PureComponent
, but as a blanket rule it has worked out well for us at Tableau. Aside from reducing the overall amount of rendering that happens it also has the nice effect of making it easier to tell why a component is being rendered too often, since you know it should only be doing so because of changes to its props or state.
Understand how react-redux works
Redux is a core part of our architecture in the Tableau mobile app, and if the same is true for your app you should understand how changes to your Redux state tree cause React components to update. We haven’t started using hooks yet so we are going to primarily be talking about mapStateToProps
, but most of what we say should apply to the newer useSelector
hook (though it is not exactly the same, as noted below).
The most important thing to understand is that your mapStateToProps
(or useSelector
) functions will be run every time a Redux action is dispatched.
That means that presentational components (in redux terminology) which get their props from mapStateToProps
(or useSelector
) may be re-rendered every time any action is dispatched.
Now react-redux will try to avoid rendering the presentational components by comparing the new result of mapStateToProps
with the prior result, doing a shallow equality comparison similar to how PureComponent
works, but it is easy to break this optimization and cause lots of unnecessary rendering if you aren’t careful about what you return.
Note that useSelector
does not do a shallow comparison on objects, rather just a simple ===
comparison, so you should definitely read up on how to use it.
Avoid creating new objects, arrays, or functions during rendering and in mapStateToProps
It is easy to accidentally break the shallow equality checks that both PureComponent
and mapStateToProps
use to avoid unnecessary rendering by always creating new objects, arrays, or functions.
A simple silly example:
In this example each of the three props getting passed to MyPureComponent
would, by themselves, break PureComponent
. Meaning that whenever the parent component re-renders, MyPureComponent
will also re-render.
This is because of how the shallow equality checking works. For objects, arrays, and functions it is checking whether the new prop is the same instance as the old prop. In the example above, the props are always new instances so the equality check with the old props will always fail.
Not avoiding this is quite a bit more risky in the mapStateToProps
case than in the PureComponent
case. Often, a component isn't individually expensive to render so you may not ever notice a problem, but because mapStateToProps
gets executed so often it can trigger lots of unnecessary rendering.
In some cases, you can solve this by turning the objects, arrays, and functions into constants or otherwise pulling them out of the function so they aren’t being recreated all the time. You can also avoid passing objects as props. For example instead of passing an object with two properties as a single prop, just create two different props.
Memoization is a good solution too, since memoized functions will compute the result (i.e. the array or object) once and cache it. Subsequent calls to the memoized function will just return the cached result. That means it will be the same instance as whatever was previously returned, and thus it won’t fail a shallow equality check. Consider using lodash’s memoize function, reselect, re-reselect, as well as React’s useMemo if you use hooks.
Avoid doing repeated expensive computations when rendering and in mapStateToProps
This may be obvious but you don’t want to be doing anything remotely expensive repeatedly during rendering, and similarly in mapStateToProps
since that gets run on every redux action. There are a variety of ways to avoid this. If you have a class component you can move the computation to your constructor
(or omponentDidMount
), though make sure you recompute it if you need to when the props change. You could also do the computation asynchronously by using the React Native InteractionManager’s runAfterInteraction
function or the standard setTimeout
function and storing the result in the state. You can also use memoization.
Understand how caching works in memoized functions
To get the benefit of memoized functions you have to understand how they cache values. You can accidentally cause a memoized function to recompute the cached value every time it is called, which defeats the whole purpose of memoization. The behavior varies by library, but we’ll cover a few problems we have hit here.
Selectors created by reselect have a cache size of one. This means that whenever they are called with different arguments they will recompute the result. So if a selector is being called often with different arguments it will not be effectively using its cache and will be creating new objects all the time. re-reselect was created to address this problem.
Also for both reselect and re-reselect you will want to make sure that the functions that you pass to createSelector
or createCachedSelector
before the computation function (e.g. F1
to FN-1
in createSelector(F1, F2, ..., FN-1, FN)
, FN
being the computation function) narrow down the inputs to the computation function to what it really needs.
For a contrived example, compare these two selectors created using reselect:
These two selectors are very similar, and compute the same result, but the second function will only recompute its result when stateTree.allFoos
changes (specifically when it is a new array). The first function will recompute its result whenever stateTree
changes, producing a new array. This will mean that any PureComponent
relying on the result of getAllFooNames1
will re-render a lot more often than you'd expect.
If you are using lodash’s memoize
function, make sure you aren't calling memoize
itself in during rendering or in mapStateToProps
. Every time you call memoize
it returns a new function with a new (empty) cache. So you will want to store that created function somewhere.
Avoid passing props unnecessarily through multiple layers of components
This can also break PureComponent
. You might have a component high up in your component tree that sets a prop (e.g. "isRefreshing"
) and passes that down to its child components. The child components don't use it, but pass the prop on down to their own children. Repeat that a few times, and finally you get to the components deep in the tree that care about the prop.
The issue is that when the prop does change all the components in the tree have to be re-rendered, even though most of them don’t even use the prop.
You can fix this a couple ways. If that prop is coming from redux (via a mapStateToProps
function), consider just turning the components that need the prop into container components so they can retrieve it from redux themselves.
A different solution would be to use the React context API. The parent component can create a context and put the prop in there. Then the components that care about the prop can just use the context to get the value.
Use the built-in profiler and the React profiler
Even if you follow all sorts of good practices you can still end up with a performance problem. When you do need to dive deeper, there are some good tools to use.
The built-in profiler (the thing you see floating over your UI when you select “Show Perf Monitor” from the developer menu) isn’t all that comprehensive but it will give you a quick feel for whether a performance problem is happening on the JavaScript side of the app or the native side.
If you think you have a problem with a specific component frequently re-rendering you can run your app with a debugger attached and try some targeted uses of console.count
in render functions. If you think some of your render functions are too expensive you could try using console.time
to time them.
That said, react-devtools lets you use the great React profiler and it will probably help you more quickly identify your problem.
The React profiler will tell you in great detail where React is spending time rendering across the whole app, and it works in React Native. It also makes it easier to test changes and be confident that you have fixed the problem.
Note that you may have to use a slightly older version of the react-devtools if you aren’t on React Native 0.62 yet:
npm install -g react-devtools@^3
It probably is obvious but things to look out for are commits that took a long time and which components took the longest time to render in those commits. The Flame chart can give you a good overall picture of what components took the most time to render, while the Ranked chart is good for finding problems in a particular commit. Also if you are using PureComponent
as a rule, also look for components that seem to be re-rendered a lot when they shouldn't be.
Writing Rendering Tests
Finally, if you have gone to the trouble of identifying and fixing a rendering problem it would be good to write a unit test to prevent it from recurring. This isn’t practical in all cases, but if you are using PureComponent
it can be pretty easy. Being able to do this easily is a great strength of React Native and the ecosystem.
At Tableau we use react-native-testing-library to do this because it supports deep rendering (enzyme only supports shallow rendering on React Native).
For class-based components, writing such a test basically involves rendering either the component you want to test directly or a component that contains the one you want to test, spying on the render function of the component you want to test, triggering some action or calling a method that might cause re-rendering to happen, and finally checking whether re-rendering happened using the spy.
Here is an example test that makes sure dispatching a random Redux action doesn’t accidentally trigger re-rendering on MyPresentationalComponent
:
Wrapping up
Well, that is a lot to take in. Hopefully this will be useful to you when getting started on a new React Native app or dealing with performance problems in your current one. Getting native-like performance with React Native is, in our experience at Tableau, in large part just understanding how things work, and knowing what patterns to use and which pitfalls to avoid.
相关故事
Subscribe to our blog
在您的收件箱中获取最新的 Tableau 更新。