In this task, you will implement an infinite scroll feature in your application. Infinite scrolling is a popular UI pattern that allows users to load more content as they scroll down the page. This feature is commonly used in social media feeds, search results, and other applications where content is paginated.
First, let’s modify the fetchPosts
function in web/src/data/api.ts
to accept pagination parameters:
// Fetch all posts
export const fetchPosts = async (
page: number = 1,
limit: number = 10,
): Promise<{ data: PostType[]; total: number }> => {
const response = await fetch(
`${API_URL}/posts?sort=desc&page=${page}&limit=${limit}`,
);
if (!response.ok) {
throw new Error(`API request failed! with status: ${response.status}`);
}
const { data, total }: { data: PostType[]; total: number } =
await response.json();
return { data, total };
};
This update allows us to fetch posts with pagination. The function now returns both the posts and the total number of posts, which we’ll use to determine if there are more posts to load.
We need to modify our store to handle the concept of pages and the ability to append new posts instead of replacing them entirely. Let’s update web/src/lib/store.ts
:
export const $posts = atom<PostType[]>([]);
export const $currentPage = atom(1);
export const $hasMorePosts = atom(true);
export function setPosts(posts: PostType[]) {
$posts.set(posts);
}
export function appendPosts(newPosts: PostType[]) {
$posts.set([...$posts.get(), ...newPosts]);
}
export function incrementPage() {
$currentPage.set($currentPage.get() + 1);
}
export function setHasMorePosts(hasMore: boolean) {
$hasMorePosts.set(hasMore);
}
// ... (rest of the file remains unchanged)
Here’s what we’ve changed:
$currentPage
to keep track of the current page, and $hasMorePosts
to indicate if there are more posts to load.appendPosts
function to add new posts to the existing list instead of replacing them.incrementPage
and setHasMorePosts
functions to update these new state values.The rest of the store remains the same for now.
We need to modify our useQueryPosts
hook to handle pagination. Let’s update the web/src/hooks/use-query-posts.tsx
file:
import { useEffect } from "react";
import { fetchPosts } from "@/data/api";
import { useStore } from "@nanostores/react";
import {
$posts,
appendPosts,
incrementPage,
setHasMorePosts,
setPosts,
} from "@/lib/store";
import { toast } from "@/components/ui/use-toast";
function useQueryPosts() {
const posts = useStore($posts);
const loadPosts = async (page: number = 1) => {
try {
const { data: fetchedPosts, total } = await fetchPosts(page);
setHasMorePosts(posts.length + fetchedPosts.length < total);
if (page === 1) {
setPosts(fetchedPosts);
} else {
appendPosts(fetchedPosts);
incrementPage();
}
} catch (error) {
const errorMessage =
(error as Error).message ?? "Please try again later!";
toast({
variant: "destructive",
title: "Sorry! There was an error reading the posts 🙁",
description: errorMessage,
});
}
};
useEffect(() => {
loadPosts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { posts, loadPosts };
}
export default useQueryPosts;
Here’s what we’ve changed:
loadPosts
function now:
page
parameter to fetch posts for a specific page.hasMorePosts
state based on the total number of posts.loadPosts
along with the posts, which we’ll use in our components.This setup allows us to load more posts as needed, which is perfect for infinite scrolling.
Let’s start with the basics and build up our InfiniteScroll
component step by step, explaining each concept as we go along.
Let’s begin with your initial component in web/src/components/post/infinite-scroll.tsx
:
import { ReactNode } from "react";
interface InfiniteScrollProps {
children: ReactNode;
}
const InfiniteScroll = ({ children }: InfiniteScrollProps) => {
return (
<div>{children}</div>
);
};
export default InfiniteScroll;
This is our starting point. Right now, it’s just a wrapper component that renders its children. We will turn this into a functional infinite scroll component later.
Next, we’ll update the Posts
component to use the InfiniteScroll
component:
import Post from "./post";
import useQueryPosts from "@/hooks/use-query-posts";
import InfiniteScroll from "./infinite-scroll";
const Posts = () => {
const { posts } = useQueryPosts();
return (
<div className="space-y-4">
<InfiniteScroll>
{posts.map((post) => (
<Post key={post.id} post={post} />
))}
</InfiniteScroll>
</div>
);
};
export default Posts;
Notice we wrapped the posts
with the InfiniteScroll
component. The array of posts will be passed as children to the InfiniteScroll
component.
Let’s add a “Load More” button to the InfiniteScroll
component. We’ll also pass a loadMore
function as a prop to handle loading more posts.
import { ReactNode } from "react";
import { Button } from "../ui/button";
interface InfiniteScrollProps {
children: ReactNode;
loadMore: () => void;
}
const InfiniteScroll = ({ children, loadMore }: InfiniteScrollProps) => {
return (
<div>
{children}
<Button onClick={loadMore}>Load More</Button>
</div>
);
};
export default InfiniteScroll;
In the code above, we’ve added a loadMore
function prop to the InfiniteScroll
component. This function will be called when the “Load More” button is clicked. This button will be displayed at the end of the list of posts.
Let’s update the Posts
component to handle loading more posts when the “Load More” button is clicked:
import Post from "./post";
import useQueryPosts from "@/hooks/use-query-posts";
import InfiniteScroll from "./infinite-scroll";
import { useStore } from "@nanostores/react";
import { $currentPage, $hasMorePosts } from "@/lib/store";
const Posts = () => {
const currentPage = useStore($currentPage);
const hasMorePosts = useStore($hasMorePosts);
const { posts, loadPosts } = useQueryPosts();
const loadMorePosts = () => {
if (!hasMorePosts) return;
loadPosts(currentPage + 1);
};
return (
<div className="space-y-4">
<InfiniteScroll loadMore={loadMorePosts}>
{posts.map((post) => (
<Post key={post.id} post={post} />
))}
</InfiniteScroll>
</div>
);
};
export default Posts;
Here’s what we’ve changed:
$currentPage
and $hasMorePosts
that keep track of the current page and whether there are more posts to load.loadMorePosts
function to handle loading more posts when the “Load More” button is clicked. This function increments the current page and calls the loadPosts
function with the new page number. It also checks if there are more posts to load before making the API call.loadMorePosts
function to the InfiniteScroll
component as a prop.Open your browser and navigate to the home page. You should see the list of posts with a “Load More” button at the end. Clicking the button should load more posts, and the button should disappear when there are no more posts to load.
IntersectionObserver
We don’t want to have user click the “Load More” button every time they reach the end of the list. Instead, we want to trigger the loading of more posts automatically when the user scrolls to the end of the list. We can achieve this using the Intersection Observer API.
Update the InfiniteScroll
component to use the Intersection Observer API:
import { ReactNode, useEffect } from "react";
import { Button } from "../ui/button";
interface InfiniteScrollProps {
children: ReactNode;
loadMore: () => void;
}
const InfiniteScroll = ({ children, loadMore }: InfiniteScrollProps) => {
useEffect(() => {
const options = {};
const callback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
console.log(entry);
});
};
const observer = new IntersectionObserver(callback, options);
const trigger = document.getElementById("trigger");
if (trigger) {
observer.observe(trigger);
}
}, []);
return (
<div>
{children}
<Button onClick={loadMore} id="trigger">
Load More
</Button>
</div>
);
};
export default InfiniteScroll;
In the code above:
useEffect
hook to create an IntersectionObserver
instance.options
object and a callback
function that logs the entries to the console.IntersectionObserver
instance with the callback
function and options
.id
of “trigger” to the “Load More” button.IntersectionObserver
instance.Open your browser and navigate to the home page. You should see the list of posts with a “Load More” button at the end. If you open the console, you should see the entries logged. Notice when the “Load More” button is in view, the related entry’s isIntersecting
property is true
. If the button is not in view, the property is false
.
Let’s update the IntersectionObserver
callback to load more posts when the “Load More” button is in view:
import { ReactNode, useEffect } from "react";
import { Button } from "../ui/button";
interface InfiniteScrollProps {
children: ReactNode;
loadMore: () => void;
}
const InfiniteScroll = ({ children, loadMore }: InfiniteScrollProps) => {
useEffect(() => {
const options = {};
const callback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.target.id === "trigger" && entry.isIntersecting) {
loadMore();
}
});
};
const observer = new IntersectionObserver(callback, options);
const trigger = document.getElementById("trigger");
if (trigger) {
observer.observe(trigger);
}
}, [loadMore]);
return (
<div>
{children}
<Button onClick={loadMore} id="trigger">
Load More
</Button>
</div>
);
};
export default InfiniteScroll;
If you run the application now, you will see we are loading more posts when the button is visible on the screen. However, there will be some errors in the console. This is because the IntersectionObserver
is not being disconnected when the component is unmounted. We will fix this in the next step.
When more posts are loaded, the Posts
component is re-rendered, and the IntersectionObserver
is re-initialized. This in turn causes the IntersectionObserver
to observe the “Load More” button multiple times, leading to multiple calls to the loadMore
function. To fix this, we need to disconnect the IntersectionObserver
when the component is unmounted.
The mechanism to clean up the IntersectionObserver
is to return a cleanup function from the useEffect
hook. This function will be called when the component is unmounted. Let’s update the InfiniteScroll
component to disconnect the IntersectionObserver
when the component is unmounted:
import { ReactNode, useEffect } from "react";
import { Button } from "../ui/button";
interface InfiniteScrollProps {
children: ReactNode;
loadMore: () => void;
}
const InfiniteScroll = ({ children, loadMore }: InfiniteScrollProps) => {
useEffect(() => {
const options = {};
const callback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.target.id === "trigger" && entry.isIntersecting) {
loadMore();
}
});
};
const observer = new IntersectionObserver(callback, options);
const trigger = document.getElementById("trigger");
if (trigger) {
observer.observe(trigger);
}
// Clean up observer on component unmount
return () => {
if (trigger) {
observer.unobserve(trigger);
}
};
}, [loadMore]);
return (
<div>
{children}
<Button onClick={loadMore} id="trigger">
Load More
</Button>
</div>
);
};
export default InfiniteScroll;
This update will ensure that the IntersectionObserver
is disconnected when the component is unmounted. It will reduce the number of errors in the console. However, if you scroll down too fast, you might still see some errors. This is because we attempt to fetch the posts while the previous fetch is still in progress. We will fix this in the next step.
Let’s update the useQueryPosts
hook to handle the loading state. If we know that a fetch operation is already in progress, we can refrain from making another fetch request. This will prevent multiple fetch requests from being made when the user scrolls down too fast.
import { useEffect, useState } from "react";
import { fetchPosts } from "@/data/api";
import { useStore } from "@nanostores/react";
import {
$posts,
appendPosts,
incrementPage,
setHasMorePosts,
setPosts,
} from "@/lib/store";
import { toast } from "@/components/ui/use-toast";
function useQueryPosts() {
const posts = useStore($posts);
const [isLoading, setIsLoading] = useState(false);
const loadPosts = async (page: number = 1) => {
setIsLoading(true);
try {
const { data: fetchedPosts, total } = await fetchPosts(page);
setHasMorePosts(posts.length + fetchedPosts.length < total);
if (page === 1) {
setPosts(fetchedPosts);
} else {
appendPosts(fetchedPosts);
incrementPage();
}
} catch (error) {
const errorMessage =
(error as Error).message ?? "Please try again later!";
toast({
variant: "destructive",
title: "Sorry! There was an error reading the posts 🙁",
description: errorMessage,
});
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadPosts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { posts, loadPosts, isLoading };
}
export default useQueryPosts;
In the code above, we’ve added a new state variable isLoading
to keep track of the loading state. We set this state to true
before making the fetch request and set it to false
after the fetch operation is complete. We also return the isLoading
state from the hook.
Posts
ComponentNext, update the Posts
component to load more posts only when the previous fetch operation is complete:
import Post from "./post";
import useQueryPosts from "@/hooks/use-query-posts";
import InfiniteScroll from "./infinite-scroll";
import { useStore } from "@nanostores/react";
import { $currentPage, $hasMorePosts } from "@/lib/store";
const Posts = () => {
const currentPage = useStore($currentPage);
const hadMorePosts = useStore($hasMorePosts);
const { posts, loadPosts, isLoading } = useQueryPosts();
const loadMorePosts = () => {
if (!hadMorePosts || isLoading) return;
loadPosts(currentPage + 1);
};
return (
<div className="space-y-4">
<InfiniteScroll loadMore={loadMorePosts}>
{posts.map((post) => (
<Post key={post.id} post={post} />
))}
</InfiniteScroll>
</div>
);
};
export default Posts;
In the code above, we get the isLoading
state from the useQueryPosts
hook. We use this state to prevent loading more posts when a fetch operation is already in progress.
Open the browser and navigate to the home page. Scroll down to the end of the list of posts. You should see more posts being loaded automatically as you scroll. The “Load More” button should not be visible until all posts are loaded.
There should be no console errors when loading more posts with the Intersection Observer.
We have a working infinite scroll feature that loads more posts automatically when the user scrolls to the end of the list. We will now refactor this into a more efficient and reusable component.
In the InfiniteScroll
component, where you define the const options = {};
, let’s add some initial options to improve the performance of the IntersectionObserver
.
const options = {
root: null,
rootMargin: "20px",
threshold: 0,
};
Here is the breakdown of the options:
root
: The element that is used as the viewport for checking visibility of the target. The default is the browser viewport. If you want to use a different element, you can set it here. We are using the default value null
.rootMargin
: Margin around the root. The default is “0px”. You can set it to a positive or negative value. We are setting it to “20px”. This means that the target will be visible when it is 20px away from the root element. This will help us to load the posts a bit earlier.threshold
: The percentage of the target’s visibility the observer’s callback should be executed. The default is 0.0. This means that the callback will be executed as soon as the target is visible. If you set this value to 1.0, the callback will be executed only when the target is fully visible.useRef
Let’s update the InfiniteScroll
component to use the useRef
hook to get a reference to the “Load More” button element:
import { ReactNode, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
interface InfiniteScrollProps {
children: ReactNode;
loadMore: () => void;
}
const InfiniteScroll = ({ children, loadMore }: InfiniteScrollProps) => {
const triggerRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const options = {
root: null,
rootMargin: "20px",
threshold: 0,
};
const callback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.target.id === "trigger" && entry.isIntersecting) {
loadMore();
}
});
};
const observer = new IntersectionObserver(callback, options);
const trigger = triggerRef.current;
if (trigger) {
observer.observe(trigger);
}
// Clean up observer on component unmount
return () => {
if (trigger) {
observer.unobserve(trigger);
}
};
}, [loadMore]);
return (
<div>
{children}
<Button ref={triggerRef} onClick={loadMore} id="trigger">
Load More
</Button>
</div>
);
};
export default InfiniteScroll;
In this update, instead of using document.getElementById("trigger")
, we are using a useRef
hook to get the reference to the button element. This is a better approach because it is more efficient and less error-prone.
useRef
useRef
is a hook that returns a mutable ref object whose .current
property is initialized to the passed argument (initialValue). The returned object will persist for the full lifetime of the component.
We use useRef
to get a reference to a DOM element or a value that persists between renders. In this case, we are using it to get a reference to the button element.
Let’s get rid of the button and use a div
element instead:
import { ReactNode, useEffect, useRef } from "react";
interface InfiniteScrollProps {
children: ReactNode;
loadMore: () => void;
}
const InfiniteScroll = ({ children, loadMore }: InfiniteScrollProps) => {
const triggerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const options = {
root: null,
rootMargin: "20px",
threshold: 0,
};
const callback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (entry.target.id === "trigger" && entry.isIntersecting) {
loadMore();
}
});
};
const observer = new IntersectionObserver(callback, options);
const trigger = triggerRef.current;
if (trigger) {
observer.observe(trigger);
}
// Clean up observer on component unmount
return () => {
if (trigger) {
observer.unobserve(trigger);
}
};
}, [loadMore]);
return (
<div>
{children}
<div ref={triggerRef} className="h-1" id="trigger" />
</div>
);
};
export default InfiniteScroll;
In this update, we’ve replaced the Button
element with a div
element. We’ve also updated the triggerRef
to use HTMLDivElement
instead of HTMLButtonElement
. This update make sense as we don’t need a button element for the “Load More” functionality.
Let’s pass more props to InfiniteScroll component to handle loading state and add a loading spinner:
import { UpdateIcon } from "@radix-ui/react-icons";
import { ReactNode, useEffect, useRef } from "react";
interface InfiniteScrollProps {
children: ReactNode;
loadMore: () => void;
hasMore: boolean;
isLoading: boolean;
}
const InfiniteScroll = ({
children,
loadMore,
hasMore,
isLoading,
}: InfiniteScrollProps) => {
const triggerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const options = {
root: null,
rootMargin: "20px",
threshold: 0,
};
const callback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (
entry.target.id === "trigger" &&
entry.isIntersecting &&
hasMore &&
!isLoading
) {
loadMore();
}
});
};
const observer = new IntersectionObserver(callback, options);
const trigger = triggerRef.current;
if (trigger) {
observer.observe(trigger);
}
// Clean up observer on component unmount
return () => {
if (trigger) {
observer.unobserve(trigger);
}
};
}, [loadMore, hasMore, isLoading]);
return (
<div>
{children}
{isLoading && (
<div className="flex justify-center py-4">
<UpdateIcon className="w-6 h-6 animate-spin" />
</div>
)}
<div ref={triggerRef} className="h-1" id="trigger" />
</div>
);
};
export default InfiniteScroll;
Posts
componentLet’s update the Posts
component to pass the hasMore
and isLoading
props to the InfiniteScroll
component:
import Post from "./post";
import useQueryPosts from "@/hooks/use-query-posts";
import InfiniteScroll from "./infinite-scroll";
import { useStore } from "@nanostores/react";
import { $currentPage, $hasMorePosts } from "@/lib/store";
const Posts = () => {
const currentPage = useStore($currentPage);
const hasMorePosts = useStore($hasMorePosts);
const { posts, loadPosts, isLoading } = useQueryPosts();
const loadMorePosts = () => {
loadPosts(currentPage + 1);
};
return (
<div className="space-y-4">
<InfiniteScroll
loadMore={loadMorePosts}
hasMore={hasMorePosts}
isLoading={isLoading}
>
{posts.map((post) => (
<Post key={post.id} post={post} />
))}
</InfiniteScroll>
</div>
);
};
export default Posts;
Notice that we’ve passed the hasMorePosts
and isLoading
states to the InfiniteScroll
component. This will allow the InfiniteScroll
component to show a loading spinner when more posts are being loaded. Moreover, we’ve removed the condition to check if isLoading
in the loadMorePosts
function. This is because the InfiniteScroll
component will handle this for us.
Open the browser and navigate to the home page. Then open the developer tools and throttle the network to simulate a slow connection. Scroll down to the end of the list of posts. You should see a loading spinner when more posts are being loaded. The spinner should disappear when all posts are loaded.
Let’s make a few improvements to the InfiniteScroll
component so that it is more reusable and flexible. We’ll add some additional props to customize the loading spinner and the trigger element.
import { UpdateIcon } from "@radix-ui/react-icons";
import { ReactNode, useEffect, useRef } from "react";
interface InfiniteScrollProps {
children: ReactNode;
loadMore: () => void;
hasMore: boolean;
isLoading: boolean;
observerOptions?: IntersectionObserverInit;
loader?: ReactNode;
}
const InfiniteScroll = ({
children,
loadMore,
hasMore,
isLoading,
observerOptions = {
root: null,
rootMargin: "20px",
threshold: 0,
},
loader = (
<div className="flex justify-center py-4">
<UpdateIcon className="w-6 h-6 animate-spin" />
</div>
),
}: InfiniteScrollProps) => {
const triggerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const callback = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (
entry.target.id === "trigger" &&
entry.isIntersecting &&
hasMore &&
!isLoading
) {
loadMore();
}
});
};
const observer = new IntersectionObserver(callback, observerOptions);
const trigger = triggerRef.current;
if (trigger) {
observer.observe(trigger);
}
// Clean up observer on component unmount
return () => {
if (trigger) {
observer.unobserve(trigger);
}
};
}, [loadMore, hasMore, isLoading, observerOptions]);
return (
<div>
{children}
{isLoading && loader}
<div ref={triggerRef} className="h-1" id="trigger" />
</div>
);
};
export default InfiniteScroll;
In the updated InfiniteScroll
component, we’ve added two new props:
observerOptions
: This prop allows you to customize the IntersectionObserver
options. You can pass any valid IntersectionObserverInit
object to this prop. By default, we are using the same options as before.loader
: This prop allows you to customize the loading spinner. You can pass any React element to this prop. By default, we are using the same loading spinner as before.useCallback
Let’s make more improvements to the InfiniteScroll
component by moving the IntersectionObserver
callback to a separate function.
import { UpdateIcon } from "@radix-ui/react-icons";
import { ReactNode, useCallback, useEffect, useRef } from "react";
interface InfiniteScrollProps {
children: ReactNode;
loadMore: () => void;
hasMore: boolean;
isLoading: boolean;
observerOptions?: IntersectionObserverInit;
loader?: ReactNode;
}
const InfiniteScroll = ({
children,
loadMore,
hasMore,
isLoading,
observerOptions = {
root: null,
rootMargin: "20px",
threshold: 0,
},
loader = (
<div className="flex justify-center py-4">
<UpdateIcon className="w-6 h-6 animate-spin" />
</div>
),
}: InfiniteScrollProps) => {
const triggerRef = useRef<HTMLDivElement | null>(null);
const observerCallback = useCallback(
(entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
if (
entry.target.id === "trigger" &&
entry.isIntersecting &&
hasMore &&
!isLoading
) {
loadMore();
}
});
},
[loadMore, hasMore, isLoading],
);
useEffect(() => {
const observer = new IntersectionObserver(
observerCallback,
observerOptions,
);
const trigger = triggerRef.current;
if (trigger) {
observer.observe(trigger);
}
// Clean up observer on component unmount
return () => {
if (trigger) {
observer.unobserve(trigger);
}
};
}, [observerOptions, observerCallback]);
return (
<div>
{children}
{isLoading && loader}
<div ref={triggerRef} className="h-1" id="trigger" />
</div>
);
};
export default InfiniteScroll;
In the code above, we moved the IntersectionObserver
callback to a separate function called observerCallback
. We also memoized the function using the useCallback
hook. This will prevent the function from being recreated on every render. It will only be recreated when the dependencies (loadMore
, hasMore
, and isLoading
) change.
useCallback
useCallback
is a hook that returns a memoized callback function. It is useful when you want to pass a function to a child component that relies on some values from the parent component.
In this case, we are using useCallback
to memoize the observerCallback
function. This will prevent the function from being recreated on every render. It will only be recreated when the dependencies (loadMore
, hasMore
, and isLoading
) change.
Since we only add one element to the IntersectionObserver
, we can simplify the code by running the callback on the first entry.
const observerCallback = useCallback(
(entries: IntersectionObserverEntry[]) => {
const entry = entries[0];
if (entry.isIntersecting && hasMore && !isLoading) {
loadMore();
}
},
[loadMore, hasMore, isLoading],
);
In the updated code, we are getting the first entry from the entries
array and checking if it is intersecting. If it is, we call the loadMore
function. This simplifies the code and makes it easier to understand.
Move the InfinityScroll
component to web/src/components/shared
directory so that it can be reused in other parts of the application.
Make sure the import path is updated in the Posts
component that uses the InfiniteScroll
component.
Now, you can use the InfiniteScroll
component in other parts of the application to implement infinite scrolling. For example, you can use it for loading more comments in a comment section or loading more search results in a search page.
Congratulations! You have successfully implemented an infinite scroll feature in your application using the Intersection Observer API. You have learned how to load more posts automatically when the user scrolls to the end of the list. You have also learned how to handle loading state and display a loading spinner while more posts are being loaded.