feat(frontend): Add reusable infinite scroll component for consistent pagination across frontend (#10530)

We currently use infinite scroll pagination in multiple places, but our
strategies vary across these locations. This repetitive code writing is
not ideal, and our current methods are also complex. We’re not utilising
React Query’s useInfiniteQuery hooks effectively.

To address these issues, we’re introducing a new component called
`InfiniteScroll` that handles pagination independently.

### How to use it?

- Use React Query’s `useInfiniteHook` to return multiple data points.
For pagination, we only need `fetchNextPage`, `hasNextPage`, and
`isFetchingNextPage`.

```ts
const {
  data: agents,
  fetchNextPage,
  hasNextPage,
  isFetchingNextPage,
  isLoading: agentLoading,
} = useGetV2ListLibraryAgentsInfinite(
  {
    page: 1,
    page_size: 8,
    search_term: searchTerm || undefined,
    sort_by: librarySort,
  },
);
```

- Simply pass these three data points and the current data length to the
`InfiniteScroll` component. That's it

```tsx
<InfiniteScroll
  dataLength={agents.length}
  isFetchingNextPage={isFetchingNextPage}
  fetchNextPage={fetchNextPage}
  hasNextPage={hasNextPage}
  loader={<LoadingSpinner />}
>
  ...
```
   
### Changes
- Add the `InfiniteScroll.tsx` component for consistency and simplicity
in pagination across the frontend.
- Update the current library page to use the `InfiniteScroll` component.

### Checklist 📋
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] I’ve tested everything locally, and it’s working perfectly fine.
This commit is contained in:
Abhimanyu Yadav
2025-08-04 11:18:54 +05:30
committed by GitHub
parent 1c3fa804d4
commit 0978f406bc
5 changed files with 165 additions and 71 deletions

View File

@@ -1,6 +1,7 @@
"use client";
import LibraryActionSubHeader from "../LibraryActionSubHeader/LibraryActionSubHeader";
import LibraryAgentCard from "../LibraryAgentCard/LibraryAgentCard";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { useLibraryAgentList } from "./useLibraryAgentList";
export default function LibraryAgentList() {
@@ -8,8 +9,9 @@ export default function LibraryAgentList() {
agentLoading,
agentCount,
allAgents: agents,
hasNextPage,
isFetchingNextPage,
isSearching,
fetchNextPage,
} = useLibraryAgentList();
const LoadingSpinner = () => (
@@ -18,7 +20,6 @@ export default function LibraryAgentList() {
return (
<>
{/* TODO: We need a new endpoint on backend that returns total number of agents */}
<LibraryActionSubHeader agentCount={agentCount} />
<div className="px-2">
{agentLoading ? (
@@ -26,18 +27,19 @@ export default function LibraryAgentList() {
<LoadingSpinner />
</div>
) : (
<>
<InfiniteScroll
dataLength={agents.length}
isFetchingNextPage={isFetchingNextPage}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
loader={<LoadingSpinner />}
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{agents.map((agent) => (
<LibraryAgentCard key={agent.id} agent={agent} />
))}
</div>
{(isFetchingNextPage || isSearching) && (
<div className="flex items-center justify-center py-4 pt-8">
<LoadingSpinner />
</div>
)}
</>
</InfiniteScroll>
)}
</div>
</>

View File

@@ -1,7 +1,5 @@
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
import { LibraryAgentResponse } from "@/app/api/__generated__/models/libraryAgentResponse";
import { useScrollThreshold } from "@/hooks/useScrollThreshold";
import { useCallback } from "react";
import { useLibraryPageContext } from "../state-provider";
export const useLibraryAgentList = () => {
@@ -12,7 +10,6 @@ export const useLibraryAgentList = () => {
hasNextPage,
isFetchingNextPage,
isLoading: agentLoading,
isFetching,
} = useGetV2ListLibraryAgentsInfinite(
{
page: 1,
@@ -34,38 +31,22 @@ export const useLibraryAgentList = () => {
},
);
const handleInfiniteScroll = useCallback(
(scrollY: number) => {
if (!hasNextPage || isFetchingNextPage) return;
const { scrollHeight, clientHeight } = document.documentElement;
const SCROLL_THRESHOLD = 20;
if (scrollY + clientHeight >= scrollHeight - SCROLL_THRESHOLD) {
fetchNextPage();
}
},
[hasNextPage, isFetchingNextPage, fetchNextPage],
);
useScrollThreshold(handleInfiniteScroll, 50);
const allAgents =
agents?.pages.flatMap((page) => {
const data = page.data as LibraryAgentResponse;
return data.agents;
agents?.pages?.flatMap((page) => {
const response = page.data as LibraryAgentResponse;
return response.agents;
}) ?? [];
const agentCount = agents?.pages[0]
const agentCount = agents?.pages?.[0]
? (agents.pages[0].data as LibraryAgentResponse).pagination.total_items
: 0;
return {
allAgents,
agentLoading,
isFetchingNextPage,
hasNextPage,
agentCount,
isSearching: isFetching && !isFetchingNextPage,
isFetchingNextPage,
fetchNextPage,
};
};

View File

@@ -0,0 +1,64 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import { useInfiniteScroll } from "./useInfiniteScroll";
interface InfiniteScrollProps {
children: React.ReactNode;
dataLength: number;
hasNextPage: boolean;
loader?: React.ReactNode;
endMessage?: React.ReactNode;
scrollThreshold?: number;
className?: string;
scrollableTarget?: string;
onLoadMore?: () => void;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
}
export const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
children,
dataLength,
hasNextPage,
loader,
endMessage,
className,
scrollThreshold = 20,
scrollableTarget,
onLoadMore,
isFetchingNextPage,
fetchNextPage,
}) => {
const { containerRef, bottomRef } = useInfiniteScroll({
isFetchingNextPage,
fetchNextPage,
scrollThreshold,
scrollableTarget,
onLoadMore,
hasNextPage,
});
const defaultLoader = (
<div className="flex w-full items-center justify-center py-4">
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-t-2 border-neutral-800" />
</div>
);
return (
<div ref={containerRef} className={cn("w-full", className)}>
{children}
{hasNextPage ? (
<div
ref={bottomRef}
className="flex w-full items-center justify-center py-8"
>
{loader || defaultLoader}
</div>
) : (
dataLength > 0 && endMessage
)}
</div>
);
};

View File

@@ -0,0 +1,84 @@
import { debounce } from "lodash";
import { useCallback, useEffect, useRef, useState } from "react";
interface useInfiniteScrollProps {
scrollThreshold: number;
scrollableTarget?: string;
onLoadMore?: () => void;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
hasNextPage: boolean;
}
export const useInfiniteScroll = ({
scrollableTarget,
onLoadMore,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
scrollThreshold,
}: useInfiniteScrollProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const [isInView, setIsInView] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const handleScroll = useCallback(() => {
if (containerRef.current && typeof window !== "undefined") {
const container = containerRef.current;
const { bottom } = container.getBoundingClientRect();
const { innerHeight } = window;
const isVisible = bottom <= innerHeight + scrollThreshold;
setIsInView(isVisible);
}
}, [scrollThreshold]);
const handleLoadMore = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const loadMore = useCallback(async () => {
if (hasNextPage && !isLoading) {
setIsLoading(true);
try {
handleLoadMore();
onLoadMore?.();
} finally {
setIsLoading(false);
}
}
}, [hasNextPage, isLoading, handleLoadMore, onLoadMore]);
useEffect(() => {
if (!hasNextPage) return;
const handleDebouncedScroll = debounce(handleScroll, 200);
const scrollElement = scrollableTarget
? document.querySelector(scrollableTarget)
: window;
if (!scrollElement) return;
scrollElement.addEventListener("scroll", handleDebouncedScroll);
handleScroll();
return () => {
scrollElement.removeEventListener("scroll", handleDebouncedScroll);
};
}, [handleScroll, hasNextPage, scrollableTarget]);
useEffect(() => {
if (isInView && hasNextPage && !isLoading) {
loadMore();
}
}, [isInView, hasNextPage, isLoading, loadMore]);
return {
containerRef,
bottomRef,
};
};

View File

@@ -1,37 +0,0 @@
import { useEffect, useState } from "react";
interface ThresholdCallback<T> {
(value: T): void;
}
export const useScrollThreshold = <T>(
callback: ThresholdCallback<T>,
threshold: number,
): boolean => {
const [prevValue, setPrevValue] = useState<T | null>(null);
const [isThresholdMet, setIsThresholdMet] = useState(false);
useEffect(() => {
const handleScroll = () => {
const { scrollY } = window;
if (scrollY >= threshold) {
setIsThresholdMet(true);
} else {
setIsThresholdMet(false);
}
if (scrollY >= threshold && (!prevValue || prevValue !== scrollY)) {
callback(scrollY as T);
setPrevValue(scrollY as T);
}
};
window.addEventListener("scroll", handleScroll);
handleScroll();
return () => window.removeEventListener("scroll", handleScroll);
}, [callback, threshold, prevValue]);
return isThresholdMet;
};