ReactJS: Modeling Bi-Directional Infinite Scrolling

noah picture noah · Jan 1, 2014 · Viewed 33.8k times · Source

Our application uses infinite scrolling to navigate large lists of heterogenous items. There are a few wrinkles:

  • It's common for our users to have a list of 10,000 items and need to scroll through 3k+.
  • These are rich items, so we can only have a few hundred in the DOM before browser performance becomes unacceptable.
  • The items are of varying heights.
  • The items may contain images and we allow the user to jump to a specific date. This is tricky because the user can jump to a point in the list where we need to load images above the viewport, which would push the content down when they load. Failing to handle that means that the user may jump to a date, but then be shifted to an earlier date.

Known, incomplete solutions:

  • (react-infinite-scroll) - This is just a simple "load more when we hit the bottom" component. It does not cull any of the DOM, so it will die on thousands of items.

  • (Scroll Position with React) - Shows how to store and restore the scroll position when inserting at the top or inserting at the bottom, but not both together.

I'm not looking for the code for a complete solution (although that would be great.) Instead, I'm looking for the "React way" to model this situation. Is scroll position state or not? What state should I be tracking to retain my position in the list? What state do I need to keep so that I trigger a new render when I scroll near the bottom or top of what is rendered?

Answer

Vjeux picture Vjeux · Jan 1, 2014

This is a mix of an infinite table and an infinite scroll scenario. The best abstraction I found for this is the following:

Overview

Make a <List> component that takes an array of all children. Since we do not render them, it's really cheap to just allocate them and discard them. If 10k allocations is too big, you can instead pass a function that takes a range and return the elements.

<List>
  {thousandelements.map(function() { return <Element /> })}
</List>

Your List component is keeping track of what the scroll position is and only renders the children that are in view. It adds a large empty div at the beginning to fake the previous items that are not rendered.

Now, the interesting part is that once an Element component is rendered, you measure its height and store it in your List. This lets you compute the height of the spacer and know how many elements should be displayed in view.

Image

You are saying that when the image are loading they make everything "jump" down. The solution for this is to set the image dimensions in your img tag: <img src="..." width="100" height="58" />. This way the browser doesn't have to wait to download it before knowing what size it is going to be displayed. This requires some infrastructure but it's really worth it.

If you can't know the size in advance, then add onload listeners to your image and when it is loaded then measure its displayed dimension and update the stored row height and compensate the scroll position.

Jumping at a random element

If you need to jump at a random element in the list that's going to require some trickery with scroll position because you don't know the size of the elements in between. What I suggest you to do is to average the element heights you already have computed and jump to the scroll position of last known height + (number of elements * average).

Since this is not exact it's going to cause issues when you reach back to the last known good position. When a conflict happens, simply change the scroll position to fix it. This is going to move the scroll bar a bit but shouldn't affect him/her too much.

React Specifics

You want to provide a key to all the rendered elements so that they are maintained across renders. There are two strategies: (1) have only n keys (0, 1, 2, ... n) where n is the maximum number of elements you can display and use their position modulo n. (2) have a different key per element. If all the elements share a similar structure it's good to use (1) to reuse their DOM nodes. If they don't then use (2).

I would only have two pieces of React state: the index of the first element and the number of elements being displayed. The current scroll position and the height of all the elements would be directly attached to this. When using setState you are actually doing a rerender which should only happen when the range changes.

Here is an example of infinite list using some of the techniques I describe in this answer. It's going to be some work but React is definitively a good way to implement an infinite list :)