Recycling Rows For High Performance React Native List Views

Tal Kol
Tal Kol
Jun 12, 2016 · 8 min read
recycling is good for the environment

Recycling previously allocated rows that went off-screen is a very popular optimization technique for list views implemented natively in iOS and Android. The default ListView implementation of React Native avoids this specific optimization in favor of other cool benefits, but this is still an awesome pattern worth exploring. Implementing this optimization under the “React state-of-mind” is also an interesting thought experiment.

Lists are a big part of mobile development

As your lists become increasingly complex, with larger sources of data, thousands of rows and rich memory-hungry media — they also become harder to implement.

On one hand, you want to keep your app fast. Scrolling at 60 FPS has become the golden standard of native UX. On the other hand, you want to keep a low memory footprint. Mobile devices are not known for their abundance of resources. It appears that winning both of these fronts is not always a simple task.

Searching for the perfect list view implementation

The same rule holds for list views. You probably won’t find a single list view implementation that will win in every use-case — while keeping both FPS high and memory consumption low.

Two types of lists

  • Nearly identical rows with a very large data-source
    A good example is a contact directory. Every contact row probably looks the same and has the same structure. We want to let users browse through many rows quickly until they find what they’re looking for.
  • High variation between rows and a smaller data-source
    A good example is a chat conversation thread. Every row here is different, and includes a variable amount of text. Some hold media. Users will typically read messages progressively and not browse through the whole thread.

The benefit of splitting the world into different use-cases is that we can offer different optimization techniques for each one.

The stock React Native list view

Another interesting property of the stock ListView is that it’s fully implemented in Javascript over the native ScrollView component that ships with React Native. If you come to React Native from a native development background either in iOS or Android, this fact probably strikes you as odd. At the foundation of the native SDK’s for both operating systems, exist time-tested native list view implementations — UITableView for iOS and ListView for Android. It’s interesting that the React Native team decided not to rely on either of them.

There are probably many reasons to why this became to be, but if I’ve had to guess I would say that it has to do with the use-cases we’ve mentioned earlier. The iOS UITableView and the Android ListView use similar optimization techniques that perform very well under the first use-case: Nearly identical rows with a very large data-source. The stock React Native ListView is simply optimized for the second.

The flagship of lists in the Facebook ecosystem is the Facebook feed. The Facebook app has been implemented natively in iOS and Android long before React Native. The initial implementation of the feed probably did rely on the native UITableView in iOS and ListView in Android, and as you can imagine, did not perform as well as expected. The feed is a classic example of the second use-case. There’s high variation between rows because each post is different, with varying amounts of content, different media and structure. Users read through the feed progressively and normally don’t browse through thousands of rows in a single sitting.

Aren’t we supposed to talk about recycling?

Reminder, the first use-case was: Nearly identical rows with a very large data-source. The main optimization technique that has proven itself useful in this scenario is recycling rows.

Since our data-source is potentially very large, we obviously can’t hold all the rows in memory at the same time. To keep memory consumption at a minimum, we would only hold in memory rows that are currently visible on screen. As the user scrolls, rows that are no longer visible will be freed, and new rows that become visible will be allocated.

The difficulty with constantly freeing and allocating rows as the user scrolls, is that this is very CPU-intensive. This naive approach will probably prevent us from reaching our 60 FPS target. Here, will come to our aid the fact that under the current use-case, the rows are nearly identical. This means that instead of freeing a row that went off-screen, we can repurpose it for a new row. We are simply going to replace the data it displays with data from the new row thus avoiding new allocations altogether.

Time to get our fingers dirty

UITableView as a native base

You might ask why aren’t we attempting to implement this technique fully in Javascript. This is an interesting topic that probably deserves a few separate blog posts to cover in-depth. In order to recycle rows properly, we must always be aware of the current scroll offset since rows must be recycled as soon as the user scrolls. Scroll events originate in the native realm and in order to reduce the number of passes over the RN bridge, it makes sense to track them natively.

In order to wrap a native component like UITableView in React Native, we’ll need to create a simple manager class in Objective-C:

The actual wrapping will be done in RNTableView.m, and mostly revolve around passing the props forward and using them in the correct places. No need to dive too deeply into the next implementation since it’s still missing the actually interesting parts:

The key concept — connecting native and JS

The best way to pass React components to our native component is as children. When we’ll use our native component from JS, by adding our rows in JSX as children, we’ll make React Native transform them to UIViews that will be provided to the native component.

The trick is that we don’t need to make components out of all the rows in the data-source. We only need a small amount of rows to display on-screen, since the entire point is to keep recycling them. Let’s take an estimated maximum of 20 rows that will be displayed on-screen at the same time. One way to make this estimate is to divide the screen height (736 logical pixels in iPhone 6 Plus by the height of every row — 50 in our case) which amounts to about 15, and add a few extras for good measure.

When these 20 rows are passed to our component as subviews on initialization, we won’t actually display them yet. We’ll just hold them in a bank of “unused cells”.

Now comes the interesting part. The native UITableView recycling works by trying to “dequeueReusableCell”. If a cell can be recycled (from a row gone off-screen), this method will return the recycled cell. If no cell can be recycled, our code needs to allocate a new one. Allocation of new cells only happens in the beginning until we fill the screen with visible rows. So how will we allocate a new cell? We’ll simply take one of the unused cells in our bank:

The last piece of the puzzle is to take the newly recycled/allocated cell and fill it with data from the data-source. Since our rows are React components, let’s translate this process to React terminology — give the row component new props based on the correct row from the data-source that we want to display.

Since changing props happens in the JS realm, we’ll need to actually do this in Javascript. This means we’ll need to communicate back the fact that we’ve changed the binding of one of our rows. We can do this by dispatching an event from native to JS:

Tying it all together

There’s one additional optimization we want to do. We want to reduce the number of re-renders to a minimum. This means we only want to re-render a row after it has been recycled and re-bound.

That’s the purpose of ReboundRenderer. This simple JS component takes as props the data-source row index that this component is currently bound to (the boundTo prop). It only re-renders itself if the binding changes (using the standard shouldComponentUpdate optimization):

Seeing it all in action

The repo also contains a few other experiments that you might find interesting. The relevant experiment among the group is tableview-children.ios.js.

Tal Kol

Written by

Tal Kol

Public blockchain for the real world. Founder at React fan. Ex head of mobile engineering. Ex Kin by Kik head of engineering.