Skip to content

Instantly share code, notes, and snippets.

@scinscinscin
Created July 22, 2023 02:27
Show Gist options
  • Save scinscinscin/998bf5d35c464bbdc5324f41204c9523 to your computer and use it in GitHub Desktop.
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
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