Simple Infinite List with React and SWR

On a recent project I had a need for an infinite list in a react project and was able to throw together a pretty solid solution that is easily adaptable to many situations without too much complexity. The rough outline of the solution is we are going to load data from the server using SWR in a paged manner, and we are going to load additional data only when the user has scrolled to the bottom of the list.

Getting Data

 

In my project I already had a paged list endpoint to work with and for this solution you’ll need an endpoint that you can either specify how many to skip and how many to take or one that takes a page number or index and returns just that subset of the data. My project used the former but I will call out how the latter can be made to work as well. As a quick note my API returned an object that has two properties, a totalCount property and an items array. My examples will reference these properties and that are specific to my data not to useSWRInfinite.

For retrieving data from the server we are going to use the SWR hook useSWRInfinite, it is similar to the more common useSWR but is designed for UI patterns such as infinite lists. This hook takes three parameters:

    • getKey
    • fetcher
    • options

Unlike useSWR “get key” is a function not a string, it will be used to either generate a unique key for each segment or the url for the API call. GetKey is called with two parameters, pageIndex and previousPageData. Page index can be used to calculate your skip if you have a skip/take api or if you have a page number api you can use it directly. PeviousPageData is populated with the previous segment and can be used to to craft the next key. I use it to check to see if we stopped receiving items as a fail safe but if your API is of the pattern to include a key or url for the next segment this is how you can get it.

const getListUrl = (pageIndex, previousPageData) => {
	if (!!previousPageData && !previousPageData.items.length) {
		// if we aren't getting any more items stop
		return null;
	}

	return `${API_HOST}/api/list?skip=${pageIndex * LIST_TAKE_COUNT}&take=${LIST_TAKE_COUNT}`;
}

The second parameter is the fetcher, this is a function that is called with the key generated in the getKey function so your fetcher just needs to take that key and make a request to wherever your data is. For this simple example this is my fetcher.

const fetcher = (url) => fetch(url).then(response => response.json());
The final parameter is an options object plus five additional options related to infinite lists that allow you to define the initial page size, revalidation logic, and how to handle keys changing (such as a filter applied). The defaults are a good starting point though.

UseSWRInfite returns the same values as you’d expect plus two additional, size and setSize. Size is the number of segments loaded, another way to think of this is it’s the number of keys or pages SWR is tracking. SetSize is simply a method to set it. One other change in the return values is data is an array of responses, the length of that array should match the size property. I find it handy to use the reduce function to collapse this array of arrays into a single array.

export const useListInfinite = () => {
	const { data, isLoading, isValidating, size, setSize } = useSWRInfinite(
		getListUrl,
		fetcher,
		{
			initialSize: 1
		});

	let items = [];
	let totalItems = 0;

	if (!!data) {
		totalItems = data[0].totalItems;
		items = data.reduce((accumulator, currentValue) => accumulator.concat(currentValue.items), []);
	}

	return {
		items,
		totalItems,
		isLoading,
		isValidating,
		size,
		setSize
	};
}

Now we have everything to get data for infinite list and we can start building up our UI.

export default function ListView() {
	const {
		items,
		totalItems,
		isLoading,
		isValidating,
		size,
		setSize
	} = useListInfinite();

	return (
		<Grid2 sx={{ p: 2 }}>
			{items.map((item, index) => {
				return (
					<Grid2 key={index} sx={{ p: 1 }} sm={12}>
						<Typography variant="h4">{item.title}</Typography>
						<Typography>{item.description}</Typography>
					</Grid2>
				);
			})}
		</Grid2>
	);
}
We’ve got a simple list view but the problem is it’s only showing one pages worth. We want it to keep loading data to fill the screen or if you scroll down the bottom of the list. We can do this by incrementing the size using setSize we are getting from useSWRInfinite when the end of the list is visible. A good way to do this is to have a UI element that is rendered at the end of the list and when it comes into view increment size. I have previously written an article about doing this with a hook here: https://www.intertech.com/tracking-an-elements-visibility-in-react/ .

I have often seen a button or link in this element with some variation of click to load more. This is probably an artifact of when IntersectionObserver wasn’t universally available so this is probably unnecessary and you could get away with just having a spinner to show loading but I’ll include this as an example of manually triggering loading more data.

<Grid2 sx={{ p: 1 }} sm={12} ref={loadMoreRef}>
	{!isLoading && items.length < totalItems ? (
		<Button onClick={() => setSize(size + 1)}>
			Load more
		</Button>
	) : null}
</Grid2>

If we leave out the ref which we will use for automatic triggering we have a manual loading example. If there are more items to retrieve the Load more button shows up. Setting up automatic loading is pretty simple, we take the ref and use the useIsInViewport hook to automatically trigger it. One note, I have intentionally made just the button disappear when loading or if no items are remaining. If we don’t render it then the IntersectionObserver can’t subscribe to its events if it’s not rendered so I hide the button leaving an empty container.

const loadMoreRef = useRef(null);
const [isInViewport, intersectionRatio] = useIsInViewport(loadMoreRef);

Now we just need to call setSize when isInViewport is true, we will use the useEffect hook for this.

useEffect(() => {
	if (isInViewport && !isLoading && !isValidating && items.length < totalItems) {
		setSize(size + 1)
	}
}, [isInViewport, isLoading, isValidating, items.length, totalItems])

All we are doing in this hook is making sure there are more items to get and we aren’t currently loading before calling setSize. If we don’t pay attention to whether or not we are currently loading data we can end up accidentally walking the whole list while we are waiting for the first segment to load. Our final list view component should look like this:

export default function ListView() {
	const {
		items,
		totalItems,
		isLoading,
		isValidating,
		size,
		setSize
	} = useListInfinite();

	const loadMoreRef = useRef(null);
	const [isInViewport, intersectionRatio] = useIsInViewport(loadMoreRef);

	useEffect(() => {
		if (isInViewport && !isLoading && !isValidating && items.length < totalItems) {
			setSize(size + 1)
		}
	}, [isInViewport, isLoading, isValidating, items.length, totalItems])

	return (
		<Grid2 sx={{ p: 2 }}>
			{items.map((item, index) => {
				return (
					<Grid2 key={index} sx={{ p: 1 }} sm={12}>
						<Typography variant="h4">{item.title}</Typography>
						<Typography>{item.description}</Typography>
					</Grid2>
				);
			})}
			<Grid2 sx={{ p: 1 }} sm={12} ref={loadMoreRef}>
				{!isLoading && items.length < totalItems ? (
					<Button onClick={() => setSize(size + 1)}>
						Load more
					</Button>
				) : null}
			</Grid2>
		</Grid2>
	);
}

Hopefully this demystifies the infinite list and gives you a better idea of how and when to use the useSWRInfinite hook.

About Intertech

Intertech is a Software Development Consulting Firm that provides single and multiple turnkey software development teams, available on your schedule and configured to achieve success as defined by your requirements independently or in co-development with your team. Intertech teams combine proven full-stack, DevOps, Agile-experienced lead consultants with Delivery Management, User Experience, Software Development, and QA experts in Business Process Automation (BPA), Microservices, Client- and Server-Side Web Frameworks of multiple technologies, Custom Portal and Dashboard development, Cloud Integration and Migration (Azure and AWS), and so much more. Each Intertech employee leads with the soft skills necessary to explain complex concepts to stakeholders and team members alike and makes your business more efficient, your data more valuable, and your team better. In addition, Intertech is a trusted partner of more than 4000 satisfied customers and has a 99.70% “would recommend” rating.