|
// Copyright 2023, Fernando Rojo. Free to use. |
|
import { |
|
useCallback, |
|
useRef, |
|
useState, |
|
useLayoutEffect, |
|
useEffect, |
|
useMemo, |
|
} from 'react' |
|
import { useQuery, UseQueryArgs, AnyVariables, useClient } from 'urql' |
|
import type { TypedDocumentNode } from '@graphql-typed-document-node/core' |
|
|
|
import deepEqual from 'react-fast-compare' |
|
|
|
const useServerLayoutEffect = |
|
typeof window !== 'undefined' ? useLayoutEffect : useEffect |
|
|
|
type Options<V extends AnyVariables = AnyVariables> = Omit< |
|
UseQueryArgs<V>, |
|
'query' | 'variables' |
|
> & { |
|
variables: Omit<V, 'offset'> |
|
} |
|
/** |
|
* Variables should be memoized here. |
|
*/ |
|
export function usePaginatedUrqlQuery< |
|
D, |
|
V extends { limit: number; offset: number } = { |
|
limit: number |
|
offset: number |
|
} |
|
>( |
|
document: TypedDocumentNode<D, V>, |
|
options: Options<V>, |
|
{ |
|
getLength, |
|
keepPreviousNonEmptyData, |
|
}: { getLength: (data: D) => number; keepPreviousNonEmptyData?: boolean } |
|
) { |
|
const [page, setPage] = useState(1) |
|
const { limit } = options.variables |
|
const offset = (page - 1) * options.variables.limit |
|
|
|
const client = useClient() |
|
|
|
// @ts-expect-error i think it's fine, just generic stuff |
|
const [query, executeQuery] = useQuery<D, V>({ |
|
query: document, |
|
...options, |
|
variables: useMemo( |
|
() => ({ |
|
...options.variables, |
|
offset, |
|
}), |
|
[options.variables, offset] |
|
), |
|
}) |
|
|
|
const previousNonEmptyData = useRef<typeof query.data>() |
|
|
|
useEffect(() => { |
|
if (query.data && getLength(query.data)) { |
|
previousNonEmptyData.current = query.data |
|
} |
|
}, [query.data]) |
|
|
|
const { fetching, stale, data, error, operation } = query |
|
|
|
const isFetchingMore = Boolean( |
|
(query.fetching || query.stale) && |
|
query.data && |
|
page > 1 && |
|
getLength(query.data) <= (page - 1) * limit |
|
) |
|
|
|
const isLoadingInitial = Boolean( |
|
page === 1 && (query.fetching || query.stale) && !query.data && !query.error |
|
) |
|
|
|
const canFetchMore = Boolean( |
|
!query.fetching && query.data && getLength(query.data) >= page * limit |
|
) |
|
|
|
const fetchMore = useCallback( |
|
() => canFetchMore && setPage((page) => page + 1), |
|
[canFetchMore] |
|
) |
|
|
|
const revalidate = useCallback( |
|
() => executeQuery({ requestPolicy: 'network-only' }), |
|
[executeQuery] |
|
) |
|
|
|
const pullToRefresh = useCallback(() => { |
|
revalidate() |
|
setPage(1) |
|
}, [revalidate]) |
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps |
|
const variablesToCompare = useMemo( |
|
() => ({ |
|
...options.variables, |
|
offset: undefined, |
|
}), |
|
[options.variables] |
|
) |
|
|
|
const previousVariables = useRef(variablesToCompare) |
|
|
|
useServerLayoutEffect( |
|
function setFirstPageOnMeaningfulVariableChange() { |
|
if (!deepEqual(previousVariables.current, variablesToCompare)) { |
|
setPage(1) |
|
} |
|
previousVariables.current = variablesToCompare |
|
}, |
|
// this isn't a deep-equal check, but it at least is better than nothing |
|
// "fast-deep-equal" will do the most on most renders. idk a way around it |
|
// eslint-disable-next-line react-hooks/exhaustive-deps |
|
Object.values(variablesToCompare) |
|
) |
|
const [isPullingToRefresh, setPullingToRefresh] = useState(false) |
|
|
|
const timer = useRef(0) |
|
|
|
const onPullToRefresh = useCallback(() => { |
|
clearTimeout(timer.current) |
|
setPullingToRefresh(true) |
|
executeQuery({ |
|
requestPolicy: 'network-only', |
|
}) |
|
new Promise<void>((resolve) => { |
|
timer.current = setTimeout(() => { |
|
clearTimeout(timer.current) |
|
resolve() |
|
}, 1000) |
|
}) |
|
.catch() |
|
.finally(() => { |
|
setPullingToRefresh(false) |
|
}) |
|
}, [executeQuery]) |
|
|
|
useEffect( |
|
() => () => { |
|
clearTimeout(timer.current) |
|
}, |
|
[] |
|
) |
|
|
|
return { |
|
fetching, |
|
stale, |
|
data: (() => { |
|
if ( |
|
keepPreviousNonEmptyData && |
|
query.data && |
|
getLength(query.data) === 0 && |
|
previousNonEmptyData.current && |
|
getLength(previousNonEmptyData.current) > 0 |
|
) { |
|
return previousNonEmptyData.current |
|
} |
|
return data |
|
})(), |
|
error, |
|
variables: operation?.variables, |
|
isPullingToRefresh, |
|
isLoadingInitial, |
|
onPullToRefresh, |
|
execute: useCallback(async () => { |
|
return executeQuery({ |
|
requestPolicy: 'network-only', |
|
}) |
|
}, [executeQuery]), |
|
refreshPageOfItemIndex: useCallback( |
|
(itemIndex: number) => { |
|
if (itemIndex < 0) { |
|
return |
|
} |
|
const pageToRefresh = Math.ceil((itemIndex + 1) / limit) |
|
|
|
client |
|
.query( |
|
document, |
|
{ |
|
offset: (pageToRefresh - 1) * limit, |
|
...options.variables, |
|
} as any, |
|
{ |
|
requestPolicy: 'network-only', |
|
} |
|
) |
|
.toPromise() |
|
}, |
|
[document, limit, client, options.variables] |
|
), |
|
canFetchMore, |
|
isFetchingMore, |
|
fetchMore, |
|
revalidate, |
|
pullToRefresh, |
|
} |
|
} |
phantom mutation implies i ask my back team to make a "fake" mutation. i didn't see a way to make a local mutation only so far to do this kind of stuff and have access to cache.
for the better way, i'm waiting for hopefully an example of what philpl have in mind because from his msg i didn't understood much 😓
it's on urql discord if you want to look at it just in case.