Skip to content

useInifiniteQuery() for infinite scrolling #178

@posva

Description

@posva

I'm trying to figure out a flexible API for useInfiniteQuery(). Goals are:

  • Allow to integrate with common practices (Elastic Search, GraphQL, others?)
  • Handle page based navigation
  • Handle cursor based navigation

Currently, I have a very simple version that lacks many features:

useInfiniteQuery() (click to expand source code)
export interface UseInfiniteQueryOptions<
  TResult,
  TError,
  TDataInitial extends TResult | undefined = TResult | undefined,
  TPages = unknown,
> extends Omit<UseQueryOptions<TResult, TError, TDataInitial>, 'query'> {
  /**
   * The function that will be called to fetch the data. It **must** be async.
   */
  query: (pages: NoInfer<TPages>, context: UseQueryFnContext) => Promise<TResult>
  initialPage: TPages | (() => TPages)
  merge: (result: NoInfer<TPages>, current: NoInfer<TResult>) => NoInfer<TPages>
}

export function useInfiniteQuery<TResult, TError = ErrorDefault, TPage = unknown>(
  options: UseInfiniteQueryOptions<TResult, TError, never, TPage>,
) {
  let pages: TPage = toValue(options.initialPage)

  const { refetch, refresh, ...query } = useQuery<TPage, TError, never>({
    ...options,
    // since we hijack the query function and augment the data, we cannot refetch the data
    // like usual
    staleTime: Infinity,
    async query(context) {
      const data: TResult = await options.query(pages, context)
      return (pages = options.merge(pages, data))
    },
  })

  return {
    ...query,
    loadMore: () => refetch(),
  }
}

It's lacking:

  • per page caching: this means no invalidation per page, no way to refetch everything again)
  • bi directional navigation: this version only has a loadMore() that works with infinite scrolling

By allowing a low level merge function, we can fully control the format of the data and we can end up with just a collection that we iterate on the client. We need initialPage to make types inferrable in merge since it's both the parameter and return type. Here is full example of it's usage with scroll:

<script setup lang="ts">
// import { factsApi, type CatFacts } from '@/composables/infinite-query'
import { useInfiniteQuery } from '@pinia/colada'
import { onWatcherCleanup, useTemplateRef, watch } from 'vue'
import { mande } from 'mande'

export interface CatFacts {
  current_page: number
  data: Array<{ fact: string, length: number }>
  first_page_url: string
  from: number
  last_page: number
  last_page_url: string
  links: Array<{
    url: string | null
    label: string
    active: boolean
  }>
  next_page_url: string | null
  path: string
  per_page: number
  prev_page_url: string | null
  to: number
  total: number
}

const factsApi = mande('https://catfact.ninja/facts')

const {
  state: facts,
  loadMore,
  asyncStatus,
  isDelaying,
} = useInfiniteQuery({
  key: ['feed'],
  query: async ({ nextPage }) =>
    nextPage != null ? factsApi.get<CatFacts>({ query: { page: nextPage, limit: 10 } }) : null,
  initialPage: {
    data: new Set<string>(),
    // null for no more pages
    nextPage: 1 as number | null,
  },
  merge(pages, newFacts) {
    // no more pages
    if (!newFacts) return pages
    // ensure we have unique entries even during HMR
    const data = new Set([...pages.data, ...newFacts.data.map((d) => d.fact)])
    return {
      data,
      nextPage: newFacts.next_page_url ? newFacts.current_page + 1 : null,
    }
  },
  // plugins
  retry: 0,
  delay: 0,
})

const loadMoreEl = useTemplateRef('load-more')

watch(loadMoreEl, (el) => {
  if (el) {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0]?.isIntersecting) {
          loadMore()
        }
      },
      {
        rootMargin: '300px',
        threshold: [0],
      },
    )
    observer.observe(el)
    onWatcherCleanup(() => {
      observer.disconnect()
    })
  }
})
</script>

<template>
  <div>
    <button :disabled="asyncStatus === 'loading' || isDelaying" @click="loadMore()">
      Load more (or scroll down)
    </button>
    <template v-if="facts?.data">
      <p>We have loaded {{ facts.data.data.size }} facts</p>
      <details>
        <summary>Show raw</summary>
        <pre>{{ facts }}</pre>
      </details>

      <blockquote v-for="fact in facts.data.data">
        {{ fact }}
      </blockquote>

      <p v-if="facts.data.nextPage" ref="load-more">Loading more...</p>
    </template>
  </div>
</template>

You can run this example locally with pnpm run play and visiting http://localhost:5173/cat-facts


I'm looking for feedback of existing needs in terms of caching and data access to implement a more feature-complete useInfiniteQuery()

Sub-issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    ⚡️ enhancementimprovement over an existing feature💬 discussiontopic that requires further discussion

    Projects

    Status

    Todo

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions