The modern age of web development that includes modularized, encapsulated web components, has brought us a plethora of tools, technologies, frameworks, and libraries of all varieties. With every such tool that is created to simplify our lives as developers in the long run, there's also a catch we sometimes neglect to consider: the cost of maintenance and performance.
In the Rookout app (built with React), we have a list containing a potentially unlimited amount of messages which are fetched using a GraphQL subscription. Essentially, every time you place a Non-Breaking Breakpoint in your code and trigger that breakpoint, a message row is displayed, which includes 3 component cells:
For this table, react-table v6 has been used since Rookout was initially built. Since users can place breakpoints almost anywhere, a single breakpoint can be triggered multiple times per second. This causes our GraphQL service to push new data to our table, which results in many rendering operations being queued by React at once.
We've known for quite a while that something in our table wasn’t as performant as it could be, but there were other more pressing issues and tasks that were prioritized at the time. However, we have lately come to the realization that the time has come to resolve this issue for good.
To illustrate the problem and how it manifests itself, here’s a visualization of how it performed prior to fixing it. This occured when scrolling and while new messages were being received (note the laggy UI and the FPS meter, specifically at the FPS count).
In the months prior to dealing with it, I personally attributed the sluggishness to the fact that our table was rendering way too many items at once and realized that windowing might just be the solution to all our problems. However, I wanted to cover all plausible reasons for this performance hit, to ensure that I didn't hit the nail just because I had a hammer in my hand.
“If all you have is a hammer, everything looks like a nail.” ~Abraham Maslow, The Psychology of Science
And so the investigation began. I took a few days to try different approaches, such as:
Eventually, I came to the conclusion that I had been right from the get-go. Although there were indeed some excess re-renders happening, they weren't the root of the issue, and the problem persisted even though I fixed them. The root of the issue was the fact that we’ve been rendering a countless amount of rows, each including 3 components, which themselves contain more components.
I knew two things for certain. The first was that we needed to leverage the windowing concept to solve our problem. The second was that we needed to find a solution that supports the current functionality we have in our table (sorting, filtering, column resizing, and row expansion).
If you’re not familiar with the concept of windowing, otherwise known as Virtualization, it basically means rendering only what the user actually sees, thus saving resources in re-renders (since there’s less components to render). Here’s a visualization of this, taken from web.dev’s excellent post about the subject.
After working my way through a bit of research, I ended up with two popular libraries that can be used for showing data in a list or a tabular format, while leveraging windowing for better performance.
The first option is react-virtualized, which was created by Brian Vaughn, who happens to be a React core team member. Virtualized supports windowing out of the box and provides a big selection of components to help you deal with a variety of use cases, including handling tables, grids, masonry layouts, and more. It’s a robust library that does a lot and does it well.
The second option is react-table in its newest version (7), by Tanner Linsley. Unlike version 6 it supports windowing using react-window, as well as filtering and sorting out of the box. Also, it includes a change in philosophy as opposed to v.6: it is not at all opinionated about how you structure or design your table. With the clever use of the library’s custom hooks, you can have your table look however you want, while creating custom interactions, using the library’s sorting and filtering mechanisms.
The big selling points of react-table had to do with its support for expanded rows, filtering, sorting and column resizing functionalities. Similar functionalities are possible in react-virtualized as well, though they do require custom code that wouldn’t be needed with react-table. Another point in its favor was the fact that react-table uses react-window as a means to handle windowing. React-window is another library created by Brian Vaughn, which was created as a leaner alternative to react-virtualized. It takes less resources and actual bundle size by supporting windowing with less additional components to handle specific use cases. It is the de-facto solution to most excessive rendering happening in lists and tables in modern apps.
Eventually, after making sure that the team agreed and had no objections, we went with react-table and react-window. The migration took a while, since some new code was required, along with studying the react-window, react-virtualized, and react-table ecosystems, as well as their respective components. Since our UI includes a lot of custom interactions and behavior, like components that need to re-render based on a debounced resizing event of the split pane in which the table resides in, among other things, it definitely wasn’t a walk in the park, but was without a doubt very worth it.
The results, compared to before, are amazing (seriously- I’m not kidding) and stay the same regardless of how many messages there are (again, look at the less-laggy UI and the FPS meter, specifically the FPS count!).
Getting to know memoization in React better is probably the greatest lesson I’ve gained from this, apart from realizing how awesome the concept of windowing is and actually putting it to use.
React-table takes memoization very seriously and uses it heavily to optimize its behavior. When I first started to research what was driving our frontend crazy, I used useMemo in several places to try and squeeze a little more out of react-table v7. Then, when I wanted to pass a second dependency to useMemo's dependency array, I noticed that for some reason it gets ignored.
After some investigation, it turned out that it happens because useMemo keeps so much data in its cache, that React sometimes clears it, making things go south. From the docs:
This made me remove most useMemo calls in this component and stay solely with the one that’s crucial for react-table’s instantiation: the columns declaration.
When optimizing performance, regardless of whether it’s a list component or an actual piece of arbitrary code, make sure to only memoize what matters. And remember! Every optimization comes with a cost.
React-window and react-table are two pieces of amazing software. They power complex lists and tables for web applications all over in a beautiful and efficient way.
The Front End codebase of Rookout is complex and contains many moving parts. Just like any other startup, sometimes certain goals, features, or bug fixes are of more importance than others. It’s near impossible to get everything you want done, so make sure that you are able to distinguish between those that you must have and those that are nice to have.
The challenge we faced was the result of an early adoption of an older version of react-table. As you know: the more users you have, the more use cases (and possibly bugs) you encounter. Some of those use cases are more performance-heavy than others and eventually you come to a point where you have to take care of them and make them better both for your users’ and your own sake.
In case you were wondering about which windowing solution fits your scenario best or how much actual value it can potentially have, well, here you have it! In a list containing an unlimited amount of items, each containing nested components of its own, react-table + react-window are our absolute favorites. Now we can move on to solve yet another problem and help our users solve theirs. :)