Building Content-Rich Pages on React Native
Traveloka’s Journey on Developing and Optimizing Vacation Product
Traveloka has been using React Native for over two years now. During that period of time, we have developed several B2B applications and some features using React Native on our hybrid Traveloka main application. One of those features is Vacation that has been released on September 2018 (v3.4).
Vacation is our first content-rich feature on Traveloka developed using React Native. Thus, prior to its release, we faced several issues, especially in terms of its performance. By writing this article, we hope that we could share our experience in performance optimization on React Native for a content-rich product.
A Glimpse of Vacation Product
Vacation product is an overarching storefront that enables showcase of all Traveloka products, including transportation, accommodation, and Local & Destination (L&D) products. The storefront is a content-rich platform that provides users with inspirational contents for endless exploration and discovery of relevant products for their needs. Through Vacation, we aim to be present for users at relevant times — be it during their travel planning stage, impromptu planning for their nights or weekends, or turning their free time into exciting discovery of new places.
Vacation consists of three main pages:
1. Main Page
2. Articles Page
3. Destinations Page
Major Performance Issues
We experienced several performance issues while developing Vacation features. These issues need to be solved separately. However, all these issues combined caused lags everywhere.
As you can see on the image, the rendering task looks really bad before we spent any optimization efforts. We were too far from 60 fps limit bar and it did not only happen on first render. It continues to perform badly on every user interaction. Thus, we need to analyze the causes of this performance issue and how to solve it, which will be explained below.
List with big resolution images
Vacation consists of a lot of pages with a lot of big resolution images on each page. This should be handled correctly for the pages to have a good performance.
Before doing any optimization efforts, we will get to see placeholders everywhere since the images being loaded have big resolution. Put apart mobile data that you need to spend to just see one image, the time it would require to show one image will significantly longer and pretty much killing the mood for content-based product. Nobody got time to see placeholder everywhere all the time.
For this issue, we had two optimizations i.e., shrinking the image to the required dimension and replacing the Image
component to native image component.
Shrinking the images is needed most as even the author image that we put on 40dp is an originally 8k one. We use an image dynamic processing host so that we can request an image based on the size that is actually needed. Therefore, to utilize that feature, we wrapped the Image
component to get its width
and height
that are defined on style
props. Then, those values are used to transform the original image source to the shrunk one.
const ResizedImage = (props) => {
const { source, style } = this.props;
const { width, height } = StyleSheet.flatten(style);
const resizedUri = getResizedUri(source.uri, { width, height });
return (
<Image {...this.props} source={{ uri: resizedUri }} />
);
}
Though, there is one condition left. How about a responsive full-width image? It gets a little tricky here. We initially request the image with a small resolution to be rendered. From there, we can get its container width with onLayout
props and the image’s width
and height
using Image.getSize
to calculate its ratio. With those two values (container width and image ratio), we can calculate the width
and height
the image needed for.
The second optimization method is to replace the ReactNative.Image
component to a native component. This arises from our early hypothesis that there should be somewhere in the ReactNative.Image
component that slows things down, especially when it needs to handle relatively big images. Our biggest bet for that is in the bridge part. The performance of the ReactNative.Image
is still acceptable for handling one single big image. However, when there are multiple images to load at the same time, the bridge is overloaded with streams of pixel data going back and forth between JavaScript and Native side.
With that assumption in mind, we thought it’d be pretty interesting to see the image being handled on the native side. We found a good library for this, react-native-fast-image (thanks to Dylan Vann). This library is fantastic that it even allows us to reduce the windowSize
props of the FlatList without sacrificing the react native pre-rendered content mechanism to prevent blank content when scrolling.
ScrollView Usage for List
To render a list of items, we initially use ScrollView
, instead of FlatList
. However, using ScrollView
in our case is not the right decision since it renders every item at once and our item is pretty heavy with images. So, we decided to use FlatList
instead.
FlatList
is an absolute delight! It allows you to render a subset of items, and on scroll, it will render the rest of the items. For a simple view, the performance of ScrollView
and FlatList
won’t even be noticeable. However, it works so well with our cases with endless scrolling which you can achieve by using onEndReached
props.
The charm of FlatList
is that there are several props that you can use for performance optimization. First, you can easily define how many items you want on initial render with initialNumToRender
. Furthermore, you can define the maximum number of items rendered outside of visible areas with windowSize
. Lastly, do not set removeClippedSubviews
props to false
since it will make the offscreen child views be rendered. These features we have mentioned help a lot since we render several images on each item, therefore the request of those images can be postponed until the actual item is shown on screen.
Unnecessary re-rendering
By default, React always re-renders a component every time its parent receive new props or changes its state by using setState
. It has its own perks, such as simplicity. Everything is out of the box. However, it does come with a price.
In our case, opacity value of the header is defined by the scroll amount of the page. The only component that must change when scroll state changes is theHeader
component. It uses opacity
, which value depends on scrollY
, as its style
. However, with an implementation like the one below, all components including ListWithBigImages
will be re-rendered.
class Container extends Component {
state = {
scrollY: new Animated.Value(0),
};render() {
const mapAnimation = [{ nativeEvent: { contentOffset: { y: this.state.scrollY } } }];
const headerArgs = {
handleOnScroll: Animated.event(mapAnimation),
opacity: this.state.scrollY.interpolate(...),
};
return (
<Fragment>
<Animated.ScrollView onScroll={handleOnScroll}>
<ListWithBigImages />
</Animated.ScrollView>
<Header style={{ opacity }} />
</Fragment>
);
}
}
It’s no big deal when unnecessary re-rendering happens on simple components but imagine that a long list with several big images on each item gets re-rendered every time scroll event happens. A bug like the images cannot be rendered well when users scroll the list can happen (it did in our case).
Here is when we could say, React.PureComponent FTW! By extending PureComponent
instead of Component
, ListWithBigImages
will not re-render on scroll event since it will not be defined as necessary by the component. Basically, the difference between them is that Component
does not implements shouldComponentUpdate
while on the other hand, PureComponent
implements it with a shallow prop and state comparison.
Nonetheless, you may need to consider using Component
instead of PureComponent
when your components contain complex data structures since it may produce false-negatives for deeper differences. By using Component
instead, you can define your own shouldComponentUpdate
for more fine-grained control.
Animations
We use animations in a lot of places, mostly for transitions and header style based on scroll event. An example for this is that we need to increase the opacity level on scroll down event and vice versa.
To have a great performance on animations using Animated API, we make sure to use the native driver. This allows the animations to be executed on the native side. It only needs to pass the bridge between JavaScript and Native once, instead of passing data back and forth on every frame. To do that, you only need to specify this props useNativeDriver: true
when starting the animation.
Animated.timing(this.state.animatedValue, {
toValue: 1,
duration: 100,
useNativeDriver: true,
}).start();
Although it’s great to pass the animation to the native driver, based on React Native documentation, there is a caveat that not everything you can do with Animated is currently supported by the native driver.
Wrapping Up
After developing several features, we learn that each feature we develop has a different type of difficulties. Through developing Vacation feature, we did get performance issues. However, it’s not an impossible task to optimize it from various angles. For us, finding a way to improve the output of our first content-rich product on Traveloka has been such a fun experience that we could learn a lot from.
This is the Part 5 in a series of articles about React Native Adoption at Traveloka.