Skip to content

Instantly share code, notes, and snippets.

@nandorojo
Last active February 1, 2024 13:26
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nandorojo/1a2ced518dc6d0ca526dad2b5b0bd0d0 to your computer and use it in GitHub Desktop.
Save nandorojo/1a2ced518dc6d0ca526dad2b5b0bd0d0 to your computer and use it in GitHub Desktop.
URQL Simple Pagination Hook implementation

This hook lets you use pagination from URQL. I needed a better solution for React Native and infinite lists.

It also has a pullToRefresh option. Since URQL's pull to refreshes are so insanely flickery, I decided to fake this completely, and make it pretend to spin for one second (plenty for most calls).

Be sure to use useMemo with your variables!

It comes with typesafety too.

const document = graphql(`
  query Users($limit: Int!, $offset: Int!) {
    users(limit: $limit, offset: $offset) {
      id
    }
  }
`)


const {
  fetching,
  stale,
  data,
  error,
  operation,
  isPullingToRefresh,
  onPullToRefresh,
  execute,
  canFetchMore,
  isFetchingMore,
  fetchMore,
  revalidate,
  pullToRefresh,
} = usePaginatedQuery(document, {
  variables: useMemo(() => {
    limit: 10,
    // any other variables, besides offset
  }, []) // be sure to memoize
}, 
{
  // required: a function that tells the hook how long your data is based on the query result
  getLength(data) { return data.users.length }
})

A few considerations:

  • This hook uses limit and offset. If you use different arguments, you should edit that in the source code.
  • limit and offset (or their replacements) must be required variables. In my experience, this also prevents bugs with simplePagination: all variables must be required for it to work properly.
  • You don't need to pass offset to the hook, since usePaginatedQuery will implement that for you.
  • This is only compatible with simplePagination from URQL. Before using this hook, you must enable simplePagination() in your URQL config for the node you are paginating. Please reference URQL's simplePagination docs. I know it's a bit confusing if you're a beginner.
// 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,
}
}
@FarooqAR
Copy link

Thanks for writing this hook! One change I'd suggest to avoid a typescript error at L96:

const timer = useRef<NodeJS.Timeout | number>(0)

@nandorojo
Copy link
Author

yeah true, at the time of writing set timeout just returned a number as the type

@FarooqAR
Copy link

canFetchMore seems finicky. It is sometimes true even though it has fetched all the data. Replacing >= with just > at L56 should fix it.

@nandorojo
Copy link
Author

yeah...i forget why exactly i used that. i feel like there was a reason, but i might be forgetting.

@nandorojo
Copy link
Author

Looks like the code was just out of date. I just updated it with improvements. As your for NodeJS.Timeout, this actually depends on your tsconfig.json of whether it targets node or not. In my case it's targeting browsers, so the type is a number.

@nandorojo
Copy link
Author

The simplest solution to the types issue ^ mentioned there is to instead to useRef<ReturnType<typeof setTimeout>>

@wcastand
Copy link

wcastand commented Feb 1, 2024

Using Flashlist and relayPagination and currently trying to get infinite scrolling, my issue is on pull to refresh, i load pages 15 items per query but when i do return executeQuery({ requestPolicy: 'network-only' }) on the onRefresh it only refresh the current page either the last one or the one i setVariable for right before. but it never clear the cache so i endup with 15items only being refresh then, since the cache is not empty, the list is still here with old data so it never trigger the onEndReached that should fetch/refetch the other pages.

Did you have a way to solve this? i can't find a way to clear cache from a query :/
i can't use the example they show in urql website since it's for web and recursive page is not possible with FlatList/FlashList

@nandorojo
Copy link
Author

Not sure exactly but consider returning back to the first page when pulling to refresh

@wcastand
Copy link

wcastand commented Feb 1, 2024

that was my first plan but it only refetch the first page and keep the cache data for the other page and won't trigger anymore so it won't refresh cache except for the first page.

currently getting help on discord for maybe a solution that fit react-native need because of FlatList requirement preventing recursive lists page.

@nandorojo
Copy link
Author

Yeah I mean one hack is to turn it into a phantom mutation and do it from there but there must be a better way

@wcastand
Copy link

wcastand commented Feb 1, 2024

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment