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
Lists are the heart and soul of mobile apps. It seems that many apps spend most of the time displaying lists — whether it’s the Facebook app with a list of posts in your feed, Messenger with lists of conversations, Gmail listing emails, Instagram listing photos, Twitter listing tweets..
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
It’s a common rule of thumb in software engineering that you can’t optimize in advance for every scenario. Let’s borrow from a different field — there is no single perfect database to hold your data. You’re probably familiar with SQL databases that excel in some use-cases, and NoSQL databases that excel in others. Since you probably won’t be implementing your own DB, your job as a software architect is often to choose the right tool for the job.
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
Roughly speaking, we can characterize two types of use-cases for lists in mobile:
- 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
React Native comes bundled with an excellent stock ListView implementation. It employs some very clever optimizations, like lazily loading rows as the user scrolls to them, reducing the number of row re-renders to a minimum and rendering rows in different event-loop cycles.
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?
If your use-case falls under the second use-case: High variation between rows and a smaller data-source — you should probably stick with the stock ListView implementation. If your use-case falls under the first use-case and you’re unhappy with how the stock implementation performs, it might be a good idea to experiment with alternatives.
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
Let’s set up a simple example to experiment with this use-case. Our example will have a very large data-source of 3000 rows having similar structure:
UITableView as a native base
As previously mentioned, there are solid implementations that do row recycling in the native SDK’s for iOS and Android. Let’s focus on iOS for now and make use of UITableView.
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.
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
You can see a fully working example containing pretty much the same code we described above in the following repo:
The repo also contains a few other experiments that you might find interesting. The relevant experiment among the group is tableview-children.ios.js.