Skip to content

Instantly share code, notes, and snippets.

@omargfh
Last active May 3, 2024 18:54
Show Gist options
  • Save omargfh/552ec3cfb199260ff0f16fb563e3154d to your computer and use it in GitHub Desktop.
Save omargfh/552ec3cfb199260ff0f16fb563e3154d to your computer and use it in GitHub Desktop.
InfiniteScroll
// InfiniteScroll.ts
// Infinite scroll component that fetches data from the server as the
// user scrolls.
//
// Author: Omar Ibrahim
// Date: 2024-03-20
// Documented: www.omar-ibrahim.com/blog/articles/5
"use client";
import { LoaderUnit } from "@/components/layout/Loading";
import { useInView } from "react-intersection-observer";
import { useEffect, useState } from "react";
import Seperator from "../image/Seperator";
import { useCacheStore } from "@/components/stores/cache";
// Define information about the server state
export interface ServerState {
loading: boolean;
error: string | null;
errorLastEncountered?: Date;
}
// Define the props for the InfiniteScroll component
export interface InfiniteScrollProps<T extends Partial<ServerState>> {
data?: T[]; // Initial data, optional
getter: (page: number) => Promise<T[]>; // Server-side getter
initialPage?: number; // Initial page, optional
cacheKey?: string; // Cache key, optional
Renderer: (props: { data: T[] }) => React.ReactNode; // Renderer for each data
hasMore?: boolean; // Whether there's more data to fetch, optional
}
// Define the state for the InfiniteScroll component,
// which is a variation of the props with additional state
export interface InfiniteScrollState<T extends Partial<ServerState>> {
data: T[]; // Current data
page: number; // Current page
loading: boolean; // Loading state
error: string | null; // Error state
errorLastEncountered?: Date; // Last time an error was encountered
hasMore: boolean; // Whether there's more data to fetch
}
// Generate the initial state for the InfiniteScroll component
export function generateInitialState<T extends Partial<ServerState>>(
data?: T[],
hasMore?: boolean,
initialPage?: number
): InfiniteScrollState<T> {
return {
data: data || [],
page: initialPage || 1,
loading: false,
error: null,
hasMore: hasMore || true,
};
}
export default function InfiniteScroll<T extends Partial<ServerState>>({
data,
getter,
cacheKey,
initialPage,
hasMore,
Renderer,
}: InfiniteScrollProps<T>) {
// Define the state for the InfiniteScroll component
const [state, setState] = useState<InfiniteScrollState<T>>(
generateInitialState(data, hasMore, initialPage)
);
// Get the cache store functions
const { getValue, setValue, _debug } = useCacheStore.getState();
// Define the ref for the IntersectionObserver
const [ref, inView] = useInView({
onChange(inView) {
if (inView) fetchMore();
},
});
// Define the fetch function
async function fetchMore() {
// Prevent fetching more data if already loading or no more data
if (state.loading || !state.hasMore) {
setTimeout(() => {
inView && fetchMore();
}, 500);
}
// Avoid refetching data if an error was encountered recently
if (
state.error &&
state.errorLastEncountered &&
new Date().getTime() - state.errorLastEncountered.getTime() < 5000
)
return;
// Set loading state
setState((prev) => ({ ...prev, loading: true }));
// If we already have the page in cache, set it
const pageKey = `page-${state.page + 1}`;
if (cacheKey) {
const cachedData = getValue(cacheKey, pageKey);
if (cachedData) {
setState((prev) => ({
data: [...prev.data, ...cachedData],
page: prev.page + 1,
loading: false,
error: null,
hasMore: cachedData.length > 0,
}));
return;
}
}
// Try fetching more data
try {
const newData = await getter(state.page + 1);
if (cacheKey) {
setValue(cacheKey, pageKey, newData);
}
setState((prev) => ({
data: [...prev.data, ...newData],
page: prev.page + 1,
loading: false,
error: null,
hasMore: newData.length > 0,
}));
} catch (error: unknown) {
setState((prev) => ({
...prev,
loading: false,
error: (error as { message: string }).message,
errorLastEncountered: new Date(),
}));
}
}
useEffect(() => {
if (inView && !state.loading) fetchMore();
}, [state.loading, inView]);
const isEmpty = state.data.length === 0;
return (
<div className="infinte-scroll">
{isEmpty && (
<span className="py-5 mx-auto opacity-40 text-center">
Nothing to see here.
</span>
)}
{!isEmpty && <Renderer data={state.data} />}
<div className="mx-auto w-full flex flex-col justify-center items-center">
{state.error && <span className="text-red-400">{state.error}</span>}
{state.loading && <LoaderUnit />}
{!state.loading && !state.hasMore && (
<Seperator width="80" className="my-[2rem]" />
)}
</div>
<div className="infinte-scroll__sentinel" ref={ref} />
</div>
);
}
"use client";
import ArticleCard from "@/components/shared/cards/ArticleCard";
import InfiniteScroll, {
InfiniteScrollProps,
ServerState,
} from "@/components/shared/pagination/InfiniteScroll";
type Article = {
id: string;
title: string;
cover: string;
href: string;
description: string | undefined;
createdAt: Date | undefined;
authorName: string | undefined;
};
export default function InfiniteScrollArticles(
props: Omit<
InfiniteScrollProps<Article & Partial<ServerState>>,
"Renderer" | "cacheKey"
> & {
id: string;
}
) {
return (
<InfiniteScroll
{...props}
cacheKey={"articles-from-tag-" + props.id}
Renderer={(props: { data: Article[] }) =>
props.data.map((article) => (
<ArticleCard
key={article.id}
{...article}
verbosity="verbose"
variant="horizontal"
/>
))
}
/>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment