-
-
Save scinscinscin/998bf5d35c464bbdc5324f41204c9523 to your computer and use it in GitHub Desktop.
Paginator - Creates a custom hook for dealing with paginating data from the API
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
import { useEffect, useState, useRef } from "react"; | |
/** | |
* useEffect but only run after initial render | |
* @param func The function to be executed whenever the deps array changes | |
* @param deps Dependency array that has to be JSON serializible | |
*/ | |
export const useEffectExceptInitial = (func: () => void, deps: any[]) => { | |
// We use a ref to keep track of the last value of deps array that the function was executed on | |
// Since the initial value of the ref is the initial value of the deps array, the function won't | |
// be executed. This also means the useEffect won't be executed twice under strict mode. | |
const lastDepsArray = useRef(JSON.stringify(deps)); | |
useEffect(() => { | |
const stringifiedDeps = JSON.stringify(deps); | |
if (lastDepsArray.current !== stringifiedDeps) { | |
lastDepsArray.current = stringifiedDeps; | |
return func(); | |
} | |
}, deps); | |
}; | |
interface PaginatorHookOptions<ResourceType, ConfigType> { | |
/** | |
* The number of posts to fetch per batch (defaults to 25 which might not be suitable for some resources) | |
* */ | |
batchCount?: number; | |
/** The initial config to use */ | |
config: ConfigType; | |
/** | |
* The initial items to be loaded. If provided, the hook starts in a loaded state. | |
* | |
* The config that was used to fetch the initial posts *must* match the config passed to the paginator hook. | |
*/ | |
initial?: { items: ResourceType[]; total?: number }; | |
} | |
interface PaginatorGeneratorOptions<ResourceType, ConfigType> { | |
/** | |
* The function called by the hook to retrieve data from the API specific to the resource type | |
* @param opts - an object containing the config, the number of posts to take and the number of posts to skip | |
* @returns - an object containing the page of items and the total number of items for the config | |
*/ | |
getResources: (opts: { | |
/** The filter options to be used in fetching the posts */ | |
config: ConfigType; | |
/** The number of posts to take */ | |
batchBy: number; | |
/** The number of posts to skip */ | |
skip: number; | |
}) => Promise<{ items: ResourceType[]; total: number }>; | |
} | |
/** | |
* Creates a custom hook for dealing with paginating data from the API | |
* @param type_ResourceType -The type of the resource to be paginated | |
* @param type_ConfigType - The parameters required by the API endpoint | |
* @param generatorOptions The options required to create the paginator | |
* @returns A custom hook that when called, provides an interface for dealing with paginated data | |
*/ | |
export function CreatePaginator<ResourceType, ConfigType>( | |
generatorOptions: PaginatorGeneratorOptions<ResourceType, ConfigType> | |
) { | |
/** | |
* A custom hook that can be used to gradually fetch resources from the API | |
* @param hookOptions - options from the hook like initial config and posts | |
* @param deps - A dependency array to reset the hook with a new config whenever anything changed. | |
*/ | |
return function (hookOptions: PaginatorHookOptions<ResourceType, ConfigType>, deps?: any[]) { | |
const batchBy = hookOptions.batchCount ?? 25; | |
const [data, setData] = useState<{ items: ResourceType[]; total: number } | null>( | |
/** We don't have to provide a total count in the initial options, so we set it to 0 and deal with it later */ | |
hookOptions.initial == null ? null : { items: hookOptions.initial.items, total: hookOptions.initial.total ?? 0 } | |
); | |
const [loading, setLoading] = useState<boolean>(hookOptions.initial?.items == null); | |
const [config, setConfig] = useState<ConfigType>(hookOptions.config); | |
/** Initial data is considered to be the 0th page. If it was provided, start at 0 else start at 1. */ | |
const [pageNumber, setPageNumber] = useState(hookOptions.initial == null ? 1 : 0); | |
useEffect(() => { | |
if (hookOptions.initial?.total == undefined) { | |
// Either no initial data was provided, or it was provided without a total count of that filter. | |
generatorOptions | |
.getResources({ config, skip: 0, batchBy }) | |
.then((response) => { | |
if (data == null) return setData(response); // no initial data was provided | |
else if (hookOptions.initial != null && hookOptions.initial.total == null) | |
// an initial data was provided but we only set the count | |
return setData({ items: data.items, total: response.total }); | |
}) | |
.catch((err) => console.log(err)) | |
.finally(() => setLoading(false)); | |
} | |
}, []); | |
/** | |
* Whenever changes to the config have been made, reset the page to 1 and | |
* fetch the new posts with that config. We don't do this for the initial | |
* because the user might pass in an initial array. We also don't append | |
* since the initial posts could be taken with a different filter config. | |
* */ | |
useEffectExceptInitial(() => { | |
setLoading(true); | |
setData(null); | |
setPageNumber(1); | |
generatorOptions | |
.getResources({ config, skip: 0, batchBy }) | |
.then((res) => setData(res)) | |
.catch((err) => console.log(err)) | |
.finally(() => setLoading(false)); | |
}, [config]); | |
/** | |
* Set the internal config of the hook whenever the config changed from the outside | |
* based on variables listed in the dependency array. We can't spread deps in the | |
* hook above as we would not be able to call setConfig without trgigering an | |
* infinite loop of rerenders. | |
**/ | |
useEffectExceptInitial(() => { | |
setConfig(hookOptions.config); | |
}, [...(deps ?? [])]); | |
const total = data?.total; | |
const skip = pageNumber * batchBy + (hookOptions.initial?.items.length ?? 0); | |
const hasPrevious = pageNumber > 1; | |
const hasMore = total == null ? false : total > skip; | |
const [loadingMore, setLoadingMore] = useState(false); | |
/** Move to the next page of entries */ | |
async function gotoNextPage() { | |
if (hasMore) { | |
try { | |
setLoading(true); | |
const res = await generatorOptions.getResources({ config, batchBy, skip: pageNumber * batchBy }); | |
setData(res); | |
setPageNumber(pageNumber + 1); | |
} catch (err) { | |
console.log("failed to load the next page", err); | |
} finally { | |
setLoading(false); | |
} | |
} | |
} | |
/** Go back to the previous page of entries */ | |
async function gotoPreviousPage() { | |
if (pageNumber > 1) { | |
try { | |
setLoading(true); | |
const res = await generatorOptions.getResources({ config, batchBy, skip: (pageNumber - 2) * batchBy }); | |
setData(res); | |
setPageNumber(pageNumber - 1); | |
} catch (err) { | |
console.log("failed to load previous page", err); | |
} finally { | |
setLoading(false); | |
} | |
} | |
} | |
/** Load the next page of entries, appending new items at the end of the current items */ | |
async function loadMore() { | |
if (hasMore) { | |
try { | |
setLoadingMore(true); | |
const res = await generatorOptions.getResources({ config, batchBy, skip }); | |
setData({ total: res.total, items: data === null ? res.items : [...data.items, ...res.items] }); | |
setPageNumber(pageNumber + 1); | |
} catch (err) { | |
console.log("failed to load more posts", err); | |
} finally { | |
setLoadingMore(false); | |
} | |
} | |
} | |
async function reload() { | |
setLoading(true); | |
setPageNumber(1); | |
try { | |
const response = await generatorOptions.getResources({ config, batchBy, skip: 0 }); | |
setData(response); | |
} catch (err) { | |
console.error("failed to reload", err); | |
} finally { | |
setLoading(false); | |
} | |
} | |
const items = data == null ? null : data.items; | |
const goto = { next: gotoNextPage, prev: gotoPreviousPage }; | |
const has = { more: hasMore, prev: hasPrevious }; | |
return { items, loading, goto, has, setConfig, loadMore, total, pageNumber, reload, loadingMore }; | |
}; | |
} | |
export type Paginator = ReturnType<ReturnType<typeof CreatePaginator<any, any>>>; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment