Skip to content

Instantly share code, notes, and snippets.

@mrcleanandfresh
Last active May 17, 2021 16:53
Show Gist options
  • Save mrcleanandfresh/f0589418fa9418f2a72ff65c647ef59a to your computer and use it in GitHub Desktop.
Save mrcleanandfresh/f0589418fa9418f2a72ff65c647ef59a to your computer and use it in GitHub Desktop.
React Virtualized Infinite loader with List example using React Hooks
import faker from 'faker';
import _ from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Col, Row } from 'react-bootstrap';
import { AutoSizer, IndexRange, InfiniteLoader, List, ListRowProps } from 'react-virtualized';
import wait from 'waait';
import { SuperProps } from './super-props';
export interface SuperListProps {
/**
* Minimum number of rows to be loaded at a time. This property can be used to batch requests to reduce HTTP
* requests. Defaults to 10.
*/
batchSize?: number,
/**
* Threshold at which to pre-fetch data. A threshold X means that data will start loading when a user scrolls
* within X rows. Defaults to 15.
*/
scrollThreshold?: number,
/**
* Reset any cached data about already-loaded rows. This method should be called if any/all loaded data needs to be
* re-fetched (eg a filtered list where the search criteria changes).
*/
isLoadMoreCacheReset?: boolean,
}
const SuperListInfinite = ( props: SuperListProps ) => {
const [ list, setList ] = useState<any[]>( [] );
const [ count, setCount ] = useState<number>( 1 );
const [ rowCount, setRowCount ] = useState<number>( 1 );
// memorizes the next value unless the list or count changes.
const hasNext = useMemo<boolean>(() => {
return count > list.length;
}, [count, list]);
/**
* Is The Row loaded
*
* This function is responsible for tracking the loaded state of each row.
*
* We chose Boolean() instead of !!list[index] because it's more performant AND clear.
* See: https://jsperf.com/boolean-conversion-speed
*/
const isRowLoaded = useCallback( ( { index } ) => {
return Boolean( list[ index ] );
}, [ list ] );
/**
* Load More Rows Implementation
*
* Callback to be invoked when more rows must be loaded. It should implement the following signature:
* ({ startIndex: number, stopIndex: number }): Promise. The returned Promise should be resolved once row data has
* finished loading. It will be used to determine when to refresh the list with the newly-loaded data. This
* callback
* may be called multiple times in reaction to a single scroll event.
*
* We wrap it in useCallback because we don't want the method signature to change from render-to-render unless one
* of the dependencies changes.
*/
const loadMoreRows = useCallback(( { startIndex, stopIndex }: IndexRange ): Promise<any> => {
const batchSize = stopIndex - startIndex;
const offset = stopIndex;
if (batchSize !== 0 || offset !== 0) {
return new Promise<any>( ( resolve ) => {
wait( 500 ).then( () => {
const newList: any[] = [];
for ( let i = offset; i < batchSize; i++ ) {
newList.push( {
id: i + 1,
name: `${faker.name.firstName( i % 2 )} ${faker.name.lastName( i % 2 )}`,
title: faker.name.title().toString(),
date: faker.date.past().toDateString(),
version: faker.random.uuid().toString(),
color: faker.commerce.color(),
} );
}
const newLists = list.concat( newList.filter( ( newItem ) => {
return _.findIndex( list, ( item ) => item.id === newItem.id ) === -1;
} ) );
// If there are more items to be loaded then add an extra row to hold a loading indicator.
setRowCount( hasNext
? newLists.length + 1
: newLists.length );
setList( newLists );
resolve();
} );
} );
} else {
return Promise.resolve();
}
}, [list, hasNext, setList, setRowCount]);
/** Responsible for rendering a single row, given its index. */
const rowRenderer = useCallback(( { key, index, style }: ListRowProps ) => {
if ( !isRowLoaded( { index } ) ) {
return (
<Row key={key} style={style}>
<Col xs={12}><span className="text-muted">Loading...</span></Col>
</Row>
);
} else {
return (
<Row key={key} style={style}>
<Col xs={1}><strong>Id</strong>: {list[ index ].id}</Col>
<Col xs={2}><strong>Name</strong>: {list[ index ].name}</Col>
<Col xs={2}><strong>Title</strong>: {list[ index ].title}</Col>
<Col xs={2}><strong>Updated</strong>: {list[ index ].date}</Col>
<Col xs={2}><strong>Version</strong>: {list[ index ].version}</Col>
<Col xs={3}><strong>Color</strong>: {list[ index ].color}</Col>
</Row>
);
}
}, [list, isRowLoaded]);
/** This effect will run on mount, and again only if the batch size changes. */
useEffect( () => {
wait( 500 ).then( () => {
const newList: any[] = [];
let batchSize;
if ( props.batchSize !== undefined ) {
batchSize = props.batchSize;
} else {
batchSize = 50;
}
for ( let i = 0; i < batchSize; i++ ) {
newList.push( {
id: i + 1,
name: `${faker.name.firstName( i % 2 )} ${faker.name.lastName( i % 2 )}`,
title: faker.name.title().toString(),
date: faker.date.past().toDateString(),
version: faker.random.uuid().toString(),
color: faker.commerce.color(),
} );
}
setList( newList );
setCount( 10000 );
} );
}, [ props.batchSize, hasNext ] );
/** If there are more items to be loaded then add an extra row to hold a loading indicator. */
useEffect(() => {
setRowCount( hasNext
? list.length + 1
: list.length );
}, [hasNext, list, setRowCount]);
return (
<InfiniteLoader
threshold={props.scrollThreshold}
isRowLoaded={isRowLoaded}
loadMoreRows={loadMoreRows}
rowCount={count}
>
{( { onRowsRendered, registerChild } ) => (
<AutoSizer disableHeight>
{( { width } ) => (
<List
height={500}
onRowsRendered={onRowsRendered}
ref={registerChild}
rowCount={rowCount}
rowHeight={100}
rowRenderer={rowRenderer}
width={width}
/>
)}
</AutoSizer>
)}
</InfiniteLoader>
);
};
export default SuperListInfinite;
@wongJonathan
Copy link

Hey I'm trying to use hooks with infinite loader and I'm having some trouble understanding this example. For the loadMoreRows, where batchSize = stopIndex - startIndex and offset = stopIndex, doesn't that mean the for loop for ( let i = offset; i < batchSize; i++ ) never runs since batchSize is always less than offset?

Also why when we mounting do we need to load data? Shouldn't loadMoreRows be updating the list?

Sorry if I'm missing something, just really confused trying to understand infinite loader with hooks.

@mrcleanandfresh
Copy link
Author

mrcleanandfresh commented May 12, 2020

I am new to this myself, and have used React Virtualized a few times at work already. So hopefully I can answer you questions, but I'm not an expert in it, so it might be a case of the blind leading the blind! :)

From now on pretend my for-loops are HTTP requests. I put them in there like that as an example, but in the real-world you'd be fetching over the network.

I load data on first mount, like you mentioned, because I wanted to give the user something to look at initially. So in that useEffect I start the request at zero, because it's the initial data. Think of the data the server gives back as a giant array, I wanted to start at index zero.

One caveat I'd like to mention here before continuing, you have to give the table a fixed height! I found with React Virtualized you have to for whatever reason, give it a height prop to start with (which is required anyway) or else there's no height... therefore no scroll event, which means loadMoreRows doesn't fire. I spent a good few hours stepping through the library code to discover this. I really thought that should be in the docs, or maybe I missed it. I wanted to save you the pain, if you haven't experienced it already.

For example's sake let's say I want to load 50 items at a time for whatever business reason. Alright, so when loadMoreRows is called, then I have already loaded 50 items from the server. So in loadMoreRows I want to start loading after item 50 because on the client I've already cached 1-50 from the server. That was our strategy. That way, the next loadMoreRows call will be 101-150, then 151-200, then 201-250, etc. This is why I use the list.length + 1 in the set row count. Whenever you have n more than your list length, React Virtualized fills n with placeholders; then, once the user scrolls within a certain buffer/threshold to those placeholders the loadMoreRows method gets fired.

Another strategy is to load it all into the client into a master list. Then whenever loadMoreRows is called, use the master list as your "server" call, so it's snappy and you save round trips to the server. Depends on how much you're loading, we were loading over 300 items, and that query took some time on the server, so we sliced it up into batches of 50.

Let me know if I answered your questions!

@ardyfeb
Copy link

ardyfeb commented Apr 10, 2021

This example cannot be used on real world use case. its just for teaching kids

@s-no1ukno
Copy link

Clean and well documented - thanks for throwing this up man!

@s-no1ukno
Copy link

One caveat I'd like to mention here before continuing, you have to give the table a fixed height! I found with React Virtualized you have to for whatever reason, give it a height prop to start with (which is required anyway) or else there's no height... therefore no scroll event, which means loadMoreRows doesn't fire.

For example's sake let's say I want to load 50 items at a time for whatever business reason. Alright, so when loadMoreRows is called, then I have already loaded 50 items from the server. So in loadMoreRows I want to start loading after item 50 because on the client I've already cached 1-50 from the server. That was our strategy. That way, the next loadMoreRows call will be 101-150, then 151-200, then 201-250, etc. This is why I use the list.length + 1 in the set row count. Whenever you have n more than your list length, React Virtualized fills n with placeholders; then, once the user scrolls within a certain buffer/threshold to those placeholders the loadMoreRows method gets fired.

How does the passed scrollThreshold prop relate to this? Maybe I'm missing it, but I don't see you actually using the scrollThreshold anywhere to be able tell it when to start loading the new list items.

@mrcleanandfresh
Copy link
Author

mrcleanandfresh commented May 17, 2021

How does the passed scrollThreshold prop relate to this?

You're not missing anything, it's in the interface declaration, but not being put inside the InfiniteScroll component in the Gist, just an oversight on my part in writing the Gist. But it would map to threshold I believe. It's been a long time since I wrote this Gist. The docs for InfiniteScroll go into slightly more detail on it, but it's pretty much what I copied/pasted above the interface prop value. It basically tells ReactVirtualized the offset from the bottom (of the previously loaded dataset) for which you want to start loading new data, at least that's the way I understood it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment