React infinite loader with TypeScript
Tags: react, typescript
Published: 2020-06-04 11:57:07

It's a performance favor solution for a list or grid of huge data. There are limited TypeScript examples to work with 'react-window-infinite-loader' and 'react-window' (A new package for react-virtualized, 'Virtualized Table', https://material-ui.com/components/tables/). I will create a functional component to work through these.

For react-window
https://github.com/bvaughn/react-window/

For react-window-infinite-loader:
https://github.com/bvaughn/react-window-infinite-loader

Encapsulated into a seperated component, reduced unnecessary properties provided with the sample:
```
import React, { ComponentType } from 'react'
import { FixedSizeList, ListChildComponentProps, Layout, ListOnScrollProps, ListItemKeySelector } from 'react-window'
import InfiniteLoader from 'react-window-infinite-loader'
import { ISearchItem } from '../views/ISearchResult'

/**
 * List item renderer properties
 */
export interface ListItemRendererProps extends ListChildComponentProps {
}

/**
 * Infinite list props
 */
export interface InfiniteListProps {
    /**
     * Is horizontal layout
     */
    horizontal?: boolean

    /**
     * Height
     */
    height?: number

    /**
     * Inital scroll offset, scrollTop or scrollLeft
     */
    initialScrollOffset?: number

    /**
     * Item unit property name, default is id
     */
    itemKey?: string

    /**
     * Item renderer
     * @param props 
     */
    itemRenderer(props: ListItemRendererProps): React.ReactElement<ListItemRendererProps>

    /**
     * Item size (height)
     */
    itemSize: number

    /**
     * Load items callback
     */
    loadItems(page: number, records: number): Promise<ISearchItem[]>

    /**
     * On scroll callback
     */
    onScroll?: (props: ListOnScrollProps) => any

    /**
     * Records to read onetime
     */
    records: number

    /**
     * Width
     */
    width?: string
}

/**
 * Infinite list state class
 */
class InfiniteListState {
    /**
     * List items
     */
    items: ISearchItem[]

    /**
     * All data is loaded
     */
    loaded: boolean

    /**
     * Current page
     */
    page: number

    /**
     * Constructor
     * @param items Init items
     */
    constructor(items: ISearchItem[]) {
        this.items = items
        this.loaded = false
        this.page = 0
    }
}

/**
 * Infinite list component
 * @param pros Properties
 */
export function InfiniteList(props: InfiniteListProps) {
    // Items state
    const [state, updateState] = React.useState(new InfiniteListState([]))

    // Render an item or a loading indicator
    const itemRenderer: ComponentType<ListChildComponentProps> = (lp) => {
        const newProps: ListItemRendererProps = {
            data: state.items[lp.index],
            index: lp.index,
            isScrolling: lp.isScrolling,
            style: lp.style,
        }
        return props.itemRenderer(newProps)
    }

    // Determine the index is ready
    const isItemLoaded = (index: number) => {
        return state.loaded || index < state.items.length
    }

    // Load more items
    const loadMoreItems = async (startIndex: number, stopIndex: number) => {
        // Loaded then return
        if(state.loaded)
            return

        // Read next page
        const page = state.page + 1
        const items = (await props.loadItems(page, props.records)) || []

        // Add to the collection
        state.items.push(...items)

        // New state
        const newState = new InfiniteListState(state.items)
        newState.page = page
        newState.loaded = items.length < props.records

        // Update
        updateState(newState)
    }

    // Add 1 to the length to indicate more data is available
    const itemCount = state.items.length + (state.loaded ? 0 : 1)

    // Default calcuated height
    const height = props.height || props.records * props.itemSize

    // Default 100% width
    const width = props.width || '100%'

    // Layout
    const layout: Layout = props.horizontal ? 'horizontal'  : 'vertical'

    // Item key
    const itemKey: ListItemKeySelector = (index, data) => {
        const field = props.itemKey || 'id'
        if(data == null || data[field] == null)
            return index

        return data[field]
    }

    // Return components
    return (
        <InfiniteLoader isItemLoaded={isItemLoaded} itemCount={itemCount} loadMoreItems={loadMoreItems} minimumBatchSize={props.records} threshold={props.records + 5}>
            {
                ({ onItemsRendered, ref }) => (
                    <FixedSizeList itemCount={itemCount}
                        onItemsRendered={onItemsRendered}
                        onScroll={props.onScroll}
                        ref={ref}
                        layout={layout}
                        itemKey={itemKey}
                        initialScrollOffset={props.initialScrollOffset}
                        itemSize={props.itemSize}
                        width={width}
                        height={height}
                    >{itemRenderer}</FixedSizeList>
                )
            }
        </InfiniteLoader>
    )
}
```
An example to use it:
```
    // Load datal
    const loadItems = async (page: number, records: number) => {
        const conditions: CustomerSearchModel = { page, records }
        return (await api.searchPersonItems(conditions)).items
    }

    // Item renderer
    const itemRenderer = (props: ListItemRendererProps) => {
        return (
            <div className={classes.listItem} style={props.style}>{props.index} {props.data == null ? 'Loading...' : props.data['name']}</div>
        )
    }

<InfiniteList itemSize={200} records={5} height={height} loadItems={loadItems} itemRenderer={itemRenderer}/>
```
Here the property 'height' of the component in vertical case is not easy to dertermine. The full height of the document, minus the app bar height, margin or padding height, is the target height. I coded a hook to calculate two elements at the same time for calculate the real height with 
```
  // Calculate dimensions, pass ref1 to AppBar (position="sticky"), ref2 to the outer Container
  const {ref1, ref2, dimensions1, dimensions2} = useDimensions2<HTMLElement, HTMLDivElement>(true)

  // Setup the actual pixel height
  const mainStyle = {
    height: (dimensions1 && dimensions2 ? (dimensions2.height - dimensions1.height) : 0)
  }

/**
 * Calculate 2 elements dimensions
 * @param observeResize Is observing resize event
 */
export function useDimensions2<E1 extends Element, E2 extends Element>(observeResize: boolean = false) {
    // References for a HTML elements passed to its 'ref' property
    const ref1 = React.useRef<E1>(null)
    const ref2 = React.useRef<E2>(null)
    
    // Dimensions and update state
    const [dimensions, updateDimensions] = React.useState<DOMRect[]>()

    // Calcuate when layout is ready
    React.useEffect(() => {
        // Update dimensions
        if(ref1.current && ref2.current)
            updateDimensions([ref1.current.getBoundingClientRect(), ref2.current.getBoundingClientRect()])

        // Resize event handler
        const resizeHandler = (event: Event) => {
            if(ref1.current && ref2.current)
                updateDimensions([ref1.current.getBoundingClientRect(), ref2.current.getBoundingClientRect()])
        }

        // Add event listener when supported
        if(observeResize)
            window.addEventListener('resize', resizeHandler)

        return () => {
            // Remove the event listener
            if(observeResize)
                window.removeEventListener('resize', resizeHandler)
        }
    }, [ref1.current, ref2.current])

    // Dimensions
    const dimensions1 = dimensions == null ? null : dimensions[0]
    const dimensions2 = dimensions == null ? null : dimensions[1]

    // Return
    return {
        ref1,
        ref2,
        dimensions1,
        dimensions2
    }
}
```
There are two additional interesting topics. How to add outer or inner elements add to the InfiniteList:
```
// Outer element
const outerElementType = React.forwardRef<HTMLElement>((p, ref) => {
    return (
        <Table innerRef={ref}>
            {p.children}
        </Table>
    )
})
```