Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

useInifiniteQuery() for infinite scrolling #178

Open
posva opened this issue Feb 3, 2025 · 2 comments
Open

useInifiniteQuery() for infinite scrolling #178

posva opened this issue Feb 3, 2025 · 2 comments
Labels
💬 discussion topic that requires further discussion ⚡️ enhancement improvement over an existing feature

Comments

@posva
Copy link
Owner

posva commented Feb 3, 2025

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()

@posva posva added ⚡️ enhancement improvement over an existing feature 💬 discussion topic that requires further discussion labels Feb 3, 2025
@github-project-automation github-project-automation bot moved this to 🆕 Triaging in Pinia Colada Roadmap Feb 3, 2025
@posva posva moved this from 🆕 Triaging to In progress in Pinia Colada Roadmap Feb 7, 2025
@unnoq
Copy link

unnoq commented Feb 8, 2025

I’m trying to add support for useInfiniteQuery in oRPC, and I have a few questions after reviewing your useInfiniteQuery code:

  1. Why does the async query function return pages instead of data?
  2. Why is the merge function's return type NoInfer<TPages> instead of NoInfer<TResult>?
  3. How do I control fetching the next page?

Looking forward to your insights!

@posva
Copy link
Owner Author

posva commented Feb 8, 2025

I'm still collecting feedback on this method. I'm sure it will change a lot in the near future so I would recommend not to support it yet.

Currently the approach is to let the user merge the data, but I realized it might be better to still keep an internal representation of the pages, that's why you see different types. loadMare() loads the next page

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
💬 discussion topic that requires further discussion ⚡️ enhancement improvement over an existing feature
Projects
Status: In progress
Development

No branches or pull requests

2 participants