-
-
Save mrcleanandfresh/f0589418fa9418f2a72ff65c647ef59a to your computer and use it in GitHub Desktop.
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; |
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 meansloadMoreRows
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 inloadMoreRows
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 nextloadMoreRows
call will be 101-150, then 151-200, then 201-250, etc. This is why I use thelist.length + 1
in the set row count. Whenever you haven
more than your list length, React Virtualized fillsn
with placeholders; then, once the user scrolls within a certain buffer/threshold to those placeholders theloadMoreRows
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.
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.
Clean and well documented - thanks for throwing this up man!