Infinite Scrolling in Astro.js: Ultimate Guide with Native & Package Solutions

Ever wonder how, when you visit a blog or social media platform like Twitter or Instagram, new posts keep appearing as you scroll down? This technique, known as infinite scrolling, has become a popular way to keep users engaged by providing a seamless, uninterrupted flow of content.

In today’s web world, infinite scrolling is a proven technique to enhance the user experience by allowing users to explore content without pause.

This approach is very useful when you have a lot of content to display without slowing down the application by fetching all content at once.

With Astro.js, known for its optimized performance and blazing-fast web experience, infinite scrolling can add interactivity to your site while maintaining Astro’s incredible speed and minimal load times.

In this article, we’ll explore two ways to implement infinite scrolling in Astro.js:

  • Using a package for quick setup and pre-built functionality.
  • A native solution with JavaScript’s Intersection Observer API for full control.

Using Package

While we can build our own infinite scroll component from scratch in many cases, it is better to use a package or library. As for the package, we will be using a popular package react-infinite-scroll-component. To implement this package, we have to use React within the Astro project. You can follow up the astro docs to know more about how to integrate React with Astro.

After integrating React within the Astro project, let’s move forward with the installation of the package for infinite scroll.

Installation

# using npm npm install --save react-infinite-scroll-component #using yarn yarn add react-infinite-scroll-component

Implementation

Now let’s move forward with the implementation and add the following the snippet to your page/component where infinite scroll need to be implmented.

<InfiniteScroll dataLength={items.length} //This is important field to render the next data next={fetchData} hasMore={true} loader={<h4>Loading...</h4>} endMessage={ <p style={{ textAlign: 'center' }}> <b>Yahooo! You have seen all the articles !!</b> </p> } > {items} </InfiniteScroll>

Before moving forward, let’s have a basic understanding of the props used in the snippet.

  • dataLength: set the length of the data.This will unlock the subsequent calls to next.
  • next: a callback function that must be called after reaching the bottom. It must trigger some sort of action that fetches the next data. 
  • hasMore: a boolean field, that tells the InfiniteScroll component whether to call next function on reaching the bottom and shows an endMessage to the user.
  • loader: a loader component to show during the next load of data.
  • endMessage: message to be shown to the user when the user has seen all the data and hasMore is false

The data is passed as children to the InfiniteScroll component and the data should contain previous items too. e.g. Initial data = [1, 2, 3] and then the next load of data should be [1, 2, 3, 4, 5, 6].

To know more about the available props you can check out the docs.

Now, the remaining steps would be,

  • Fetch the initial data when the page loads
  • Implement api/query to fetch next page data
  • Implement further modification with infinite scroll as per your requirement.

As the data fetching and initial setup might be different for individuals, we will skip those and your final infinite scroll component code should look like below snippet,

const [posts, setPosts] = useState(initialPost); const [count, setCount] = useState(1); const [currentCount, setCurrentCount] = useState(1); const [total, setTotal] = useState(null); const fetchAllPosts = async () => { //data fetching logic goes here } const fetchPosts = async () => { const {data} = await fetchAllPosts(count); setPosts(data?.posts); setCurrentCount(data?.currentLength); setTotal(data?.total); setCount(count +1); }; return ( <section> <InfiniteScroll dataLength={posts?.length} next={fetchPosts} hasMore={currentCount !== total} loader={<h4>Loading...</h4>} endMessage={ <p style={{ textAlign: "center" }}> <b>Yahooo! You have seen all the articles !!</b> </p> } > {posts.map((post) => ( <PostComponent key={post.id} post={post} id={post.id} /> ))} </InfiniteScroll> </section> );

Now, let’s break down the snippet to know how infinite scroll component works,

  • In the snippet the value of dataLength gets assigned to the number of posts fetched for the first time, which is the length of the posts in this scenario.
  • When the user scrolls down, the hasMore prop tells the infinite-scroll component that if it is true, initiate the function available in the next prop. In our snippet, when the current count or current length of the posts is not equal to the total no. of posts, call the fetchPosts function.
  • Then fetchPosts is called with the updated count value, then more posts are fetched and the value of the posts array is updated along with the value of dataLength.
  • While the posts are being fetched, the content inside the loader prop is displayed. Ideally, this can be a skeleton component or a loading animation.
  • If hasMore is false or in other words, all the posts are fetched from the database, the element inside the endMessage prop is displayed after the last post.
  • Inside the InfiniteScroll component, PostComponent is used to display the post content.

While using a package is easy and time-saving, this approach is not for every website. If your website focuses more on the performance side, then using the package will be less useful as it depends on react, which may decrease the expected performance of the website. To tackle that issue, we will now look into our second approach, which is implementing the infinite scroll using the native approach.

Native Solution

Infinite Scroll

In this approach, we will use JavaScript’s Intersection Observer API which provides greater control over your infinite scroll behavior.

Step 1: Creating Observer

We will create an observer, which watches for when the user nears the end of the page, triggering content loading.

const sentinelObserver = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { loadMoreContent(); } }); }, { root: null, rootMargin: `${getLastSectionOffset()}px`, threshold: 0, } ); // Start observing sections and the sentinel element const sentinel = document.querySelector('#sentinel'); if (sentinel) sentinelObserver.observe(sentinel); function observeContent(element) { if (element && contentObserver) { contentObserver.observe(element); } } document.querySelectorAll("#content-container > section").forEach((section) => observeContent(section));

The sentinelObserver observes a sentinel element that signals when to load more content.

# Add this at the end of the component <div id="sentinel" class="sentinel" style="height: 10px; width: 100%;"></div>

Step 2: Calculate the Trigger Point

The trigger point is calculated based on the last section’s height, allowing control over when content loads, this can be adjusted to load content sooner or later depending upon the use case.

function getLastSectionOffset() { const sections = document.querySelectorAll('#content-container > section'); const lastSection = sections[sections.length - 1]; return lastSection ? lastSection.offsetHeight / 2 : 0; }

getLastSectionOffset calculates a scroll point halfway through the last section, which can be tuned for a smoother experience.

Step 3: Loading Content

import axios from 'axios'; async function loadMoreContent() { const previousContentLinks = document.querySelectorAll('.previous-content-link'); const lastContentLink = previousContentLinks[previousContentLinks.length - 1]; if (!lastContentLink) return; try { const response = await axios.get(lastContentLink.href); const doc = new DOMParser().parseFromString(response.data, 'text/html'); const newContent = doc.querySelector('#content-container > section'); // Optional: Remove unnecessary elements newContent?.querySelectorAll('.remove-on-load')?.forEach((el) => el.remove()); // Insert new content before the sentinel const mainContainer = document.querySelector('#content-container'); if (mainContainer && !mainContainer.querySelector(`section[data-uri="${lastContentLink.href}"]`)) { mainContainer.insertBefore(newContent, document.querySelector('.sentinel')); observeContent(newContent); // Start observing the new content } } catch (error) { console.error('Failed to load content:', error); } }

In the above snippet, new content is fetched based on the last available link, and then the content is parsed, cleaned, and added to the page before the sentinel, ready for further observation.

Step 5: Cleaning Up Observers

For optimal performance and to prevent memory leaks, it’s essential to clean up the observers when the page unloads.

window.addEventListener('beforeunload', () => { if (sentinel) sentinelObserver.unobserve(sentinel); sentinelObserver.disconnect(); });

After going through all the above steps your code should look like this,

import axios from 'axios'; export const infiniteScroll = () => { if (typeof window !== 'undefined') { function getLastSectionOffset() { const sections = document.querySelectorAll('#content-container > section'); const lastSection = sections[sections.length - 1]; return lastSection ? lastSection.offsetHeight / 2 : 0; } async function loadMoreContent() { const previousContentLinks = document.querySelectorAll('.previous-content-link'); const lastContentLink = previousContentLinks[previousContentLinks.length - 1]; if (!lastContentLink) return; try { const response = await axios.get(lastContentLink.href); const doc = new DOMParser().parseFromString(response.data, 'text/html'); const newContent = doc.querySelector('#content-container > section'); newContent?.querySelectorAll('.remove-on-load')?.forEach((el) => el.remove()); const mainContainer = document.querySelector('#content-container'); if (mainContainer && !mainContainer.querySelector(`section[data-uri="${lastContentLink.href}"]`)) { mainContainer.insertBefore(newContent, document.querySelector('.sentinel')); observeContent(newContent); } } catch (error) { console.error('Failed to load more content:', error); } } const sentinelObserver = new IntersectionObserver( (entries) => entries.forEach((entry) => entry.isIntersecting && loadMoreContent()), { root: null, rootMargin: `${getLastSectionOffset()}px`, threshold: 0 } ); const sentinel = document.querySelector('#sentinel'); if (sentinel) sentinelObserver.observe(sentinel); function observeContent(element) { if (element && contentObserver) contentObserver.observe(element); } document.querySelectorAll("#content-container > section").forEach((section) => observeContent(section)); window.addEventListener('beforeunload', () => { if (sentinel) sentinelObserver.unobserve(sentinel); sentinelObserver.disconnect(); }); } };

This approach provides a more controlled way to implement infinite scroll behaviour in your website while keeping the optimized performance provided by Astro.

Feedback is appreciated regarding this approach.

While both approaches provide you the same functionality considering your use cases will help you create an optimized, user friendly infinite scrolling experience on your Astro.js site. Once implemented, this functionality will provide a seamless content flow that keeps users engaged and ensures smooth transitions as they scroll.