Infinite scrolling React grid which only renders the visible grid tiles, but recycles them to show "new" tiles: Similar to React Native FlatList

Viewed 1749

Problem Explanation

I have grid with a bunch of square tiles in it(about 120). If only 8 tiles are visible at a time, then I only want to render about 16 tiles.

  • The 8 visible tiles, plus a buffer

When scrolling, when the top 4 or so tiles scroll off the screen, these tiles would be recycled, have the information in them replaced with the information for next 4 tiles, and then be placed in the buffer so that they are displayed when the user scrolls a bit more.

This really optimizes the component if you have a lot of tiles, because you only need to render the ones that are visible, plus a buffer.


Further Reference

This kind of concept is implemented in React Native's Flatlist, as well as Recycler View. I have seen some node modules which look like a react.js version of a Flatlist, but I do not think most of these will work, because I am trying to implement this concept using a CSS Grid type layout.

I have also heard this problem described as dequeuing


Best online solution

After looking at a lot of node modules, I have found an example node module of this concept which uses a grid type layout called InfiniteScrollGrid, which has a live demo. The main problem with this node module is that it is old, unmaintained, uses old React styles, has security vulnerabilities, and I couldn't even get it to compile.

One other issue is that I would like the individual tiles to maintain their original width:height ratio as the user changes the width of their web browser


Attempted Solution

Below I have tried to implement the functionality of the node module myself using hooks. I've tried my best to replicate it, but feel like I haven't fully wrapped my head around all the code in that node module.

There is a codesandbox and github repo for the example below.

  • Note: For some reason the codesandbox has some errors that do not appear locally even though I forked the sandbox from the Github

InfiniteScrollGrid.js

import React, {useState, useEffect, useRef, memo} from 'react';
import PropTypes from 'prop-types';
import Item from './Item';

//Inifinately scrolling grid, that only renders as many items as visible on the screen
//Based on React Native FlatList, and this Github Repo https://github.com/ggordan/react-infinite-grid
const InfiniteScrollGrid =  memo(({
    buffer = 10,
    padding = 10,
    entries = [],
    height = 250,
    width = 250,
    wrapHeight,
    lazyCallback,
    renderRangeCallback
}) => {

    if (!entries.length) return null;

    const itemHeightConst = height + (2 * padding);
    const itemWidthConst = width + (2 * padding);

    const wrapper = useRef();
    const grid = useRef();
    const [initiatedLazyload, setInitiatedLazyload] = useState(false);
    const [numEntries, setNumEntries] = useState(entries.length);
    const [minHeight, setMinHeight] = useState(2);
    const [minItemIndex, setMinItemIndex] = useState(0);
    const [maxItemIndex, setMaxItemIndex] = useState(100);
    const [wrapperHeight, setWrapperHeight] = useState(wrapHeight);

    const [itemHeight, setItemHeight] = useState(itemHeightConst);
    const [itemWidth, setItemWidth] = useState(itemHeightConst);
    const [itemGridWidth, setItemGridWidth] = useState(0);
    const [itemsPerRow, setItemsPerRow] = useState(2);

    const [initiatedLazyLoad, setInitiatedLazyLoad] = useState(false);

    const gridHeight = Math.floor(entries.length / itemsPerRow) * itemHeight;
    const getItemsPerRow = Math.floor(grid?.current?.clientWidth / itemWidthConst);
    const numVisibleRows = Math.ceil(wrapper?.current?.height / itemHeight);
    const scrolledPastRows = Math.floor((grid?.current?.height - grid?.current?.bottom) / itemHeight);

    const [scrollOffset, setScrollOffset] = useState('');

    const styles = {
        wrapper: {
            maxHeight: grid?.current?.clientHeight,
            overflowY: 'scroll',
            width: '100%',
            height: wrapperHeight,
            WebkitOverflowScrolling: true
        },
        grid: {
            position: 'relative',
            marginTop: padding,
            marginLeft: padding,
            minHeight: grid?.current?.clientHeight
        }
    };

    const totalRows = () => {
        const scrolledPastHeight = (entries.length / itemsPerRow) * itemHeightConst;
        return scrolledPastHeight < 0 ? 0 : scrolledPastHeight;
    };

    const visibleIndexes = () => {

        // The number of rows that the user has scrolled past
        let scrolledPast = Math.max((scrolledPastRows * itemsPerRow), 0);
        let min = Math.max((scrolledPast - itemsPerRow), 0);

        let bufferRows = numVisibleRows + buffer;
        let max = min(scrolledPast + (itemsPerRow * bufferRows), entries.length);

        setMinItemIndex(min);
        setMaxItemIndex(max);
    };

    const updateItemDimensions = () => {
        // setItemHeight(itemHeightConst);
        // setItemWidth(itemWidthConst);
        setItemGridWidth(grid?.current?.width);
        setItemsPerRow(getItemsPerRow());
        setMinHeight(totalRows());
    };

    const scrollListener = (event) => {
        clearTimeout(scrollOffset);
        setScrollOffset(setTimeout(() => visibleIndexes(), 10));
    };

    useEffect(() => {

        const resizeListener = () => {
            if (!wrapperHeight) setWrapperHeight(window?.innerHeight);
            updateItemDimensions();
            visibleIndexes();
        };

        window.addEventListener('resize', resizeListener);
        updateItemDimensions();
        visibleIndexes();
        return window.removeEventListener('resize', resizeListener);
    }, []);

    useEffect(() => {       //TODO: May need to disable on the first render
        if (!initiatedLazyload && (maxItemIndex === entries.length) && lazyCallback) {
            setInitiatedLazyLoad(true);
            lazyCallback(maxItemIndex);
        };
    }, [minItemIndex, maxItemIndex]);

    useEffect(() => {
        if (entries.length > numEntries) {
            setInitiatedLazyLoad(false);
            setNumEntries(entries.length);
        };
        visibleIndexes();
    }, [entries]);

    useEffect(() => {
        if (typeof renderRangeCallback === 'function') {
            renderRangeCallback(minItemIndex, maxItemIndex);    //TODO: What is this about
        };
    }, [buffer, padding, entries, height, width, wrapHeight, lazyCallback, renderRangeCallback, initiatedLazyload, numEntries, minHeight, minItemIndex, maxItemIndex, wrapperHeight, itemHeight, itemWidth, itemGridWidth, itemsPerRow, initiatedLazyLoad]);    //What are prev props and prev state

    return (
        <div
            ref={wrapper}
            className='infinite-grid-wrapper'
            onScroll={scrollListener}
            style={styles.wrapper}
        >
            <div ref={grid} className='infinite-grid' style={styles.grid}>
                {entries.slice(minItemIndex, maxItemIndex).map((entry, i) => (
                    <Item
                        key={`item-${i}`}
                        index={minItemIndex + i}
                        padding={padding}
                        width={itemWidth}
                        height={itemHeight}
                        itemsPerRow={itemsPerRow}
                        data={entry}
                    />
                ))}
            </div>
        </div>
    );

});

InfiniteScrollGrid.propTypes = {
    buffer: PropTypes.number,
    padding: PropTypes.number,
    entries: PropTypes.number,
    height: PropTypes.number,
    width: PropTypes.number,
    wrapHeight: PropTypes.number,
    lazyCallback: PropTypes.func,
    renderRangeCallback: PropTypes.func
};

export default InfiniteScrollGrid;

Item.js

import React, {memo} from 'react';
import PropTypes from 'prop-types';

const Item = memo(({width, itemsPerRow, index, height, padding, data}) => {

    const style = {
        width: (width / itemsPerRow) - padding,
        height: height - padding,
        left: (index % itemsPerRow) * (width / itemsPerRow),
        top: Math.floor((index / itemsPerRow) * height),
        position: 'absolute'
    };

    return (
        <div style={style} className="item">
            <div>{data}</div>
        </div>
    );
});

Item.propTypes = {
    width: PropTypes.number,
    itemsPerRow: PropTypes.number,
    index: PropTypes.number,
    height: PropTypes.number,
    padding: PropTypes.number,
    data: PropTypes.any
};

export default Item;
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>

Example Usage

import React from 'react';
import InfiniteScrollGrid from './index';

const ExampleItem = ({index}) => (
    <div className='example'>
        This is {index}
    </div>
);

export default () => {
 
    let items = [];
    for (let i = 0; i <= 1000; i++) {
        items.push(<ExampleItem index={i} />);
    };

    const lazyCallback = (index) => {
        console.log(index);
    };
    
    return (
        <InfiniteScrollGrid entries={items} wrapperHeight={400} lazyCallback={lazyCallback} />
    );
};


I would like a working solution of this infinately scrolling grid component

3 Answers

You'd want to look into virtualization. react-virtualized and react-window are quite popular options

What you want is often refered as windowing.

I would recommand react-window created by main React engineer Brian Vaughn.

Here's a example where he's using it.

I don't think that "react-virtualized" or something similar will help you. You are in an app and if you want to process a lot of data, you have to load it first. Loading large amounts of data and then displaying them in a virtual list is not a real scenario. What you need is an extended FlatList. The normal FlatList has a "threshold" value and an "onEndReached" method, which is sufficient to expand the list. The list fills up from time to time and the application slower, so you need an additional "threshold" value and an "onStartReached" method. The "threshold" value is to be selected based on the loading time and the "on ... Reached" method loads a reasonable amount of data. Since you are not alone with this problem, there are already finished implementations that you can use as a guide. I say as guide because we all only cook with water. I searched for "bi-directional infinite scroll" on google and found it. I highly recommend using the standard components of react native and extend them.

Happy Programming!

Related