Next.js Infinite Loader with Server Components
React Server Components have changed the way we think about rendering process in React. Instead of focusing on one environment at a time, components can render on the server and the client simultaneously.
This can be problematic when it comes to infinite loading, because server components don't have access to the state. For example, if we have a cursor pagination mechanism for data fetching, we need to keep track of the previously loaded pages on the client. Easiest way would be to store fetched data in the state and every time user reaches the end of the list, trigger new request with updated cursor and update state with fresh awaited data.
I would definetely recommend using library like React-Query for this task, because it has built-in support for infinite loaders. You can still prefetch data in server components and seed query cache with initial data, which gives instant feedback to the user.
Let's get started
For this example, I will use GitHub API to fetch list of users, because it uses cursor pagination. You need to create GITHUB_TOKEN environment variable if you want to follow along. This is our getUsers function:
const getUsers = async (page: number): Promise<PaginatedResponse<GithubUser[]>> => {
const PER_PAGE = 12;
const url = new URL("https://api.github.com/users");
const params = new URLSearchParams({
since: "0",
per_page: `${page * PER_PAGE}`,
});
url.search = params.toString();
const response = await fetch(url, {
headers: {
"X-GitHub-Api-Version": "2022-11-28",
Authorization: `Bearer ${process.env.NEXT_PUBLIC_GITHUB_TOKEN}`,
},
});
if (!response.ok) throw new Error("Failed to fetch users");
const users = (await response.json()) as GithubUser[];
const hasNextPage = users.length === page * PER_PAGE;
return { data: users, nextPage: hasNextPage ? page + 1 : null };
};
Epxerienced eye will notice that we are actually NOT using cursor pagination, because if we did, then instead of page, we would pass cursor as function parametar. This is because we can't store previous cursor results in the state, so we fetch bigger chunks (per_page: page * PER_PAGE).
Displaying users
One thing that is amazing with server components is that you can fetch data directly in the component, just by marking component as async. Let's display our first chunk of users.
// File: app/page.tsx
// getUsers function ...
export default async function Home() {
const users = await getUsers(1); // Fetch first chunk of users
return (
<main className="flex flex-col items-center gap-8 p-8">
<h1 className="text-3xl font-bold">GitHub Users</h1>
<div className="max-h-[400px] w-[400px] overflow-y-scroll pr-4">
<ul className="flex flex-col items-center gap-8 bg-slate-700 p-4">
{users.data.map((item) => (
<li key={item.id} className="relative h-[100px] w-[100px] overflow-hidden rounded-full">
<Image
src={item.avatar_url}
alt={item.id.toString()}
width={100}
height={100}
priority
/>
</li>
))}
</ul>
</div>
</main>
);
}
This was not so challenging, right?
Before reading further, can you think about where should we store the state of the current page for infinite loader? We can't use
state because server components don't have access to it. The answer is really simple and it will blow your mind.
Storing server state
Have you ever used URL query parameters to store state? This is the perfect use case for it. We can store the current page in the URL query parameter and update it every time user reaches the end of the list. This way, we can fetch fresh data on the server and update the list without any problems.
Don't get intimidated by the next piece of code, it's really simple. We will make useInfiniteScroll hook that will handle updating the URL query parameter every time user scrolls near the list end. Focus on the observerCallback function, because that's where the magic happens.
// File: src/hooks/use-infinite-scroll.ts
interface Args {
lastElementRef: MutableRefObject<HTMLDivElement | null>;
nextPage: number | null;
}
const useInfiniteScroll = ({ lastElementRef, nextPage }: Args) => {
const observerElem = useRef<IntersectionObserver | null>(null);
const searchParams = useSearchParams();
const pathname = usePathname();
const { replace } = useRouter();
const [isPending, startTransition] = useTransition();
const observerCallback = useCallback(
(entries: IntersectionObserverEntry[]) => {
if (!entries[0].isIntersecting || nextPage === null) return;
const params = new URLSearchParams(searchParams);
params.set("page", nextPage.toString());
startTransition(() => {
replace(`${pathname}?${params.toString()}`);
});
},
[nextPage, pathname, replace, searchParams],
);
const observer = useCallback(
(node: HTMLDivElement) => {
if (isPending) return;
if (observerElem.current) observerElem.current.disconnect();
observerElem.current = new IntersectionObserver(observerCallback);
if (node) observerElem.current.observe(node);
},
[isPending, observerCallback],
);
useEffect(() => {
const currentElement = lastElementRef.current;
if (currentElement) observer(currentElement);
return () => {
if (observerElem.current && currentElement) {
observerElem.current.unobserve(currentElement);
}
};
}, [lastElementRef, observer]);
return { isPending };
};
export default useInfiniteScroll;
You must be thinking, Alpha Code you are so smart! I know, I know, but let's not get carried away, we have project to make here.
Thing where I got most excited about is useTransition hook. When fetching new data from the server, we don't know when the data will be ready. With startTransition function we can tell React to mark this update as low-priority. The transition stays in the pending state while React is waiting for the new URL update to settle, at that point our data is already available on the server. While transition is happening, we can show loading spinner to user, improving our UX by 1000x. All this with 0 state management code.
Loading Spinner and Last Element Ref
We need to add loading spinner and attach lastElementRef to his parent container. This is the only component where we need to add "use client" directive, because we are using refs. Loading spinner will be shown when:
- User scrolls near the end of the list (Observer will trigger URL update)
- We are in pending state
- We have more pages to fetch
// File: src/components/loading-spinner.tsx
"use client";
import useInfiniteScroll from "@/hooks/use-infinite-scroll";
import { useRef } from "react";
interface Props {
nextPage: number | null;
}
const LoadingSpinner = ({ nextPage }: Props) => {
const lastElementRef = useRef<HTMLDivElement | null>(null);
const { isPending } = useInfiniteScroll({
lastElementRef,
nextPage,
});
return (
<div className="flex w-full items-center justify-center bg-slate-500 p-4" ref={lastElementRef}>
{isPending && (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="animate-spin"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
)}
</div>
);
};
export default LoadingSpinner;
We are doing great so far! Let's quickly extract the list of users to separate component.
// File: src/components/infinite-list.tsx
import { GithubUser } from "@/models/api";
import Image from "next/image";
interface Props {
users: GithubUser[];
}
const InfiniteList = ({ users }: Props) => {
return (
<ul className="flex flex-col items-center gap-8 bg-slate-700 p-4">
{users.map((item) => (
<li key={item.id} className="relative h-[100px] w-[100px] overflow-hidden rounded-full">
<Image src={item.avatar_url} alt={item.id.toString()} width={100} height={100} priority />
</li>
))}
</ul>
);
};
export default InfiniteList;
Putting it all together with Search params
We still need to find a way to trigger fetching of new data when user scrolls near the end of the list. In server components, we have access to searchParams in props. This the final piece of the puzzle.
// File: app/page.tsx
import InfiniteList from "@/components/infinite-list";
import LoadingSpinner from "@/components/loading-spinner";
import { GithubUser, PaginatedResponse } from "@/models/api";
// getUsers function ...
interface UserSearchParams {
page: string | null;
}
export default async function Home({ searchParams }: { searchParams: UserSearchParams }) {
const users = await getUsers(Number(searchParams?.page ?? 1));
return (
<main className="flex flex-col items-center gap-8 p-8">
<h1 className="text-3xl font-bold">GitHub Users</h1>
<div className="max-h-[400px] w-[400px] overflow-y-scroll pr-4">
<InfiniteList users={users.data} />
<LoadingSpinner nextPage={users.nextPage} />
</div>
</main>
);
}
And that's it! We have implemented infinite loader with server components.
Conclusion
Let's recap what we have learned:
- Server components don't have access to the state
- We can store state in URL query parameters
- We can use useTransition hook to show loading spinner while fetching data from server
- We can use IntersectionObserver to trigger URL update when user scrolls near the end of the list
- We can use useSearchParams hook to access URL query parameters in server components and trigger new data fetch
We at Alpha Code love to share our knowledge with the community. If you have any questions or suggestions, feel free to reach out to us. If you need help with setting up your project, code refactoring, or just want to chat, we are here for you.
Full code can be found on GitHub.