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,
}
}
@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