Last active
May 3, 2024 18:54
-
-
Save omargfh/552ec3cfb199260ff0f16fb563e3154d to your computer and use it in GitHub Desktop.
InfiniteScroll
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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> | |
); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"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