React Machine Coding Series - Virtualised Infinite Scroll


If you’ve been in the frontend development space for a while, you’ve probably heard these three magical keywords: Virtualisation, Debouncing, and Infinite Scroll.
In today’s article, we’ll implement an use case that combines all three concepts and learn more about them.
What is infinite scroll and why do we need it in first place?
Infinite scroll only fetches new data when the user scrolls down the page. Usually data fetching happens when the user reaches the end of the page.
Infinite scroll only fetches and populates data on demand. It saves bandwidth and memory by avoiding loading unnecessary data, which is especially important for users with limited resources or slow connections.
Problem Statement:
Implement a User Interface which loads and display feeds on scroll (Infinite Scroll).
Common use cases include news feeds on social media, product listings on e-commerce sites, image galleries, search results pages, and chat histories in messaging apps.
In this article, we will build a product listing page for an e-commerce website that loads additional products whenever the user scrolls to the bottom of the page.
Tech Stack used
In this comprehensive guide, we will use React along with JSX to implement infinite scroll.
Infinite Scroll Setup
We will organise our HTML into three div elements.
View port container
Feed Container
Product Elements
Viewport Container:
This is the visible area that the user sees on their screen. Its width and height matches the dimensions of the viewport.
Feed Container:
This container holds all the product elements inside it.
Product Element:
Contains product title and product image.
import React from 'react';
export const InfiniteScroll = () => {
return (
<div
id = 'viewport-container'
style = {{
width: '100vw',
height: '100vh',
position: 'relative',
overflow: 'auto'
}}
>
<div
id = 'feed-container'
style = {{
width: '100%',
height: '', // has to be dynamic
position: 'relative'
}}
>
{
// contains product elements (div)
}
</div>
</div>
)
}
We need to fetch data whenever the user comes to the end of page. How do we determine that?
We can identify using
Amount of scrolling that has already occurred.
The height of the viewport (the visible area of the user’s screen).
The total height of the feed content currently loaded (such as the list of products).
To improve user experience, we fetch new content when the user is almost at the end by adding a buffer.
The logic boils down to
if (current_scroll_position + view_port_height ≥ available_feed_content-buffer){
// fetch new content
}
To get the current scroll position, we add an onScroll handler to the viewport container, which listens to all scroll events on that container. We also add a ref to the feed container to get DOM data (height) of that container.
import React, { useCallback, useRef, useState } from 'react';
export const InfiniteScroll = () => {
const containerRef = useRef();
const [scrollTop, setScrollTop] = useState(0);
const handleInfiniteScroll = useCallback((scrollTop) => {
const windowHeight = window.innerHeight;
const documentTotalHeight = containerRef.current?.offsetHeight;
const buffer = 2000;
if(windowHeight + scrollTop >= documentTotalHeight - buffer){
// fetch new content
}
}, []);
const onScroll = useCallback((e) => {
setScrollTop(e.target.scrollTop);
handleInfiniteScroll(e.target.scrollTop);
}, []);
return (
<div
id = 'viewport-container'
style = {{
width: '100vw',
height: '100vh',
position: 'relative',
overflow: 'auto'
}}
onScroll = {onScroll}
>
<div
id = 'feed-container'
style = {{
width: '100%',
height: '',
position: 'relative'
}}
ref = {containerRef}
>
{
// contains product elements (div)
}
</div>
</div>
)
}
Now the infinite scroll setup is almost done. Next, we need to fetch products.
Fetch Data
In my case, I am going to use this https endpoint (https://dummyjson.com/products)
The endpoint accepts two query parameter
skip (Number of products to skip)
limit (Total number of products to get after skip)
Example: (https://dummyjson.com/products?skip={20}&limit={10})
I got this endpoint from ChatGPT. You can use any endpoint that supports offset-based pagination.
We will use a cursor state to store the offset and a products state to store the list of fetched products. We will use useEffect to make a fetch call to get products during the initial mount.
Height of the feed container has to be total number of products * one product card height.
import React, { useCallback, useEffect, useRef, useState } from 'react';
const LIMIT = 10;
export const InfiniteScroll = () => {
const containerRef = useRef();
const [scrollTop, setScrollTop] = useState(0);
const [products, setProducts] = useState([]);
const [cursor, setCursor] = useState(0);
// code for infinite scroll
const fetchProducts = async (cursor) => {
try{
const url = `https://dummyjson.com/products?limit=${LIMIT}&skip=${cursor}`;
let response = await fetch(url);
let data= await response.json();
setProducts(pr => [...pr, ...data.products]);
setCursor(cursor + LIMIT);
}
catch(error){
console.log(error);
}
}
const handleInfiniteScroll = useCallback((scrollTop, cursor) => {
const windowHeight = window.innerHeight;
const documentTotalHeight = containerRef.current?.offsetHeight;
const buffer = 2000;
if(windowHeight + scrollTop >= documentTotalHeight - buffer){
// fetch new content
fetchProducts(cursor);
}
}, []);
const onScroll = useCallback((e) => {
setScrollTop(e.target.scrollTop);
handleInfiniteScroll(e.target.scrollTop, cursor);
}, [cursor]);
useEffect(() => {
fetchProducts(cursor);
}, []);
return (
<div
style = {{
width: '100vw',
height: '100vh',
position: 'relative',
overflow: 'auto'
}}
onScroll = {onScroll}
>
<div
style = {{
width: '100%',
height: products.length * 400,
position: 'relative'
}}
ref = {containerRef}
>
{
products.map((product, index) => {
return (
<div
id = 'product-card'
style = {{
width: '50%',
height: '400px',
border: '1px solid black',
display: 'flex',
flexWrap: 'wrap'
}}
>
<div>
<h3>{product.title}</h3>
<img src={product.thumbnail}></img>
</div>
</div>
)
})
}
</div>
</div>
)
}
Redundant Data
If you check the network tab in Developer Tools, you will see data is fetched on demand, but sometimes the same resource is fetched multiple times, leading to redundant data and a poor user experience.
Why does this happen?
A fetch call is triggered every time the user scrolls beyond the buffered height. API calls are asynchronous and take some time to complete. During the time between the first API call and its completion, subsequent API calls may be triggered. Since we are only appending APIs response to our products state this leads to redundant data.
How to avoid this?
We will use debouncing technique.
What is debouncing?
Debouncing is a programming technique used to control the rate at which a function is executed. It ensures that a function is not called too frequently, especially when triggered by events like user input or window resizing, by delaying its execution until after a specified period of time has passed since the last invocation. This helps prevent performance issues and unwanted behavior caused by rapid function calls.
The handleInfiniteScroll function is called every time the user scrolls. We need to limit the rate at which handleInfiniteScroll is called.
We will write a custom hook that takes a function and delay as parameters and returns a debounced function.
export const useDebouncedFunction = (fn, delay) => {
const timeout = useRef(null);
const debounced = useCallback((...args) => {
if (timeout.current !== null) clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
fn(...args);
timeout.current = null;
}, delay);
}, [fn]);
return debounced;
};
Whenever the returned function is invoked, it checks if a timeout already exists. If so, it clears the previous timeout and sets a new one. The original function will only be executed after the specified delay has passed without any further calls interrupting the timeout.
const debouncedFunction = useDebouncedFunction(handleInfiniteScroll, 100);
const onScroll = useCallback((e) => {
setScrollTop(e.target.scrollTop);
debouncedFunction(e.target.scrollTop, cursor);
}, [debouncedFunction, cursor]);
YAY! Infinite Scroll is done!
What have you accomplished?
Fetching products on demand.
Avoiding redundant API calls using debouncing.
Performance Issue
Open your developer tools and go to the Elements tab. Expand every DOM element. Now scroll your feed. Every time new content is loaded, we are adding DOM elements to our HTML document. If there are thousands of products in your feed, this will exponentially increase your document size, leading to performance concerns.
As you can see DOM elements are getting added when user scrolls.
We need to identify few metrics:
How many products we can show in the screen.
What is the starting product that we are going to show in the screen.
const numberOfProductsToShow = Math.ceil(window.innerHeight / 400) + 2;
const startingProduct = Math.floor(scrollTop / 400);
Number of products that we can show equals the view port height divided by total height of a product card. Add a buffer of two cards to provide seamless flow.
Index of the starting product equals the scroll position divided by total height of a product card.
We have to display only numberofProductsToShow products from startingProduct.
{
products.map((product, index) => {
if(index < startingProduct - 1 || index > startingProduct + numberOfProductsToShow) return null;
return (
<div
id = 'product-card'
style = {{
width: '100%',
height: '400px',
border: '1px solid black',
display: 'flex',
flexWrap: 'wrap',
position: 'absolute',
top: index * 400
}}
>
<div>
<h3>{product.title}</h3>
<img src={product.thumbnail}></img>
</div>
</div>
)
})
}
We have to style the position of the product card as absolute to position it relative to its parent and tweek its top value depending on the index.
Now if you inspect the DOM elements, you can see that the DOM elements are reused instead of creating new one. This technique is called Virtualisation.
That’s it for today!
You can find the source code here.
Happy Coding :)
Do let me know if this was helpful in the comments below and if would like a dive into any other topic.
Subscribe to my newsletter
Read articles from Akil Vishnu directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
