Docs
Infinite Query Hook

Infinite Query Hook

React hook for infinite lists, fetching data from Supabase.

Installation

Folder structure

  • hooks
1'use client'
2
3import { PostgrestQueryBuilder, type PostgrestClientOptions } from '@supabase/postgrest-js'
4import { type SupabaseClient } from '@supabase/supabase-js'
5import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
6
7import { createClient } from '@/lib/supabase/client'
8
9const supabase = createClient()
10
11// The following types are used to make the hook type-safe. It extracts the database type from the supabase client.
12type SupabaseClientType = typeof supabase
13
14// Utility type to check if the type is any
15type IfAny<T, Y, N> = 0 extends 1 & T ? Y : N
16
17// Extracts the database type from the supabase client. If the supabase client doesn't have a type, it will fallback properly.
18type Database =
19  SupabaseClientType extends SupabaseClient<infer U>
20    ? IfAny<
21        U,
22        {
23          public: {
24            Tables: Record<string, any>
25            Views: Record<string, any>
26            Functions: Record<string, any>
27          }
28        },
29        U
30      >
31    : {
32        public: {
33          Tables: Record<string, any>
34          Views: Record<string, any>
35          Functions: Record<string, any>
36        }
37      }
38
39// Change this to the database schema you want to use
40type DatabaseSchema = Database['public']
41
42// Extracts the table names from the database type
43type SupabaseTableName = keyof DatabaseSchema['Tables']
44
45// Extracts the table definition from the database type
46type SupabaseTableData<T extends SupabaseTableName> = DatabaseSchema['Tables'][T]['Row']
47
48// Default client options for PostgrestQueryBuilder
49type DefaultClientOptions = PostgrestClientOptions
50
51type SupabaseSelectBuilder<T extends SupabaseTableName> = ReturnType<
52  PostgrestQueryBuilder<
53    DefaultClientOptions,
54    DatabaseSchema,
55    DatabaseSchema['Tables'][T],
56    T
57  >['select']
58>
59
60// A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.
61type SupabaseQueryHandler<T extends SupabaseTableName> = (
62  query: SupabaseSelectBuilder<T>
63) => SupabaseSelectBuilder<T>
64
65interface UseInfiniteQueryProps<T extends SupabaseTableName, Query extends string = '*'> {
66  // The table name to query
67  tableName: T
68  // The columns to select, defaults to `*`
69  columns?: string
70  // The number of items to fetch per page, defaults to `20`
71  pageSize?: number
72  // A function that modifies the query. Can be used to sort, filter, etc. If .range is used, it will be overwritten.
73  trailingQuery?: SupabaseQueryHandler<T>
74  // Optional key that identifies the current trailing query shape (e.g. filters/sort/search).
75  // When this changes, the internal store is recreated so stale paginated rows are discarded.
76  trailingQueryKey?: unknown
77}
78
79interface StoreState<TData> {
80  data: TData[]
81  count: number
82  isSuccess: boolean
83  isLoading: boolean
84  isFetching: boolean
85  error: Error | null
86  hasInitialFetch: boolean
87}
88
89type Listener = () => void
90
91interface StoreProps<T extends SupabaseTableName> {
92  tableName: T
93  columns?: string
94  pageSize?: number
95  getTrailingQuery: () => SupabaseQueryHandler<T> | undefined
96}
97
98function createStore<TData extends SupabaseTableData<T>, T extends SupabaseTableName>(
99  props: StoreProps<T>
100) {
101  const { tableName, columns = '*', pageSize = 20, getTrailingQuery } = props
102
103  let state: StoreState<TData> = {
104    data: [],
105    count: 0,
106    isSuccess: false,
107    isLoading: false,
108    isFetching: false,
109    error: null,
110    hasInitialFetch: false,
111  }
112
113  const listeners = new Set<Listener>()
114
115  const notify = () => {
116    listeners.forEach((listener) => listener())
117  }
118
119  const setState = (newState: Partial<StoreState<TData>>) => {
120    state = { ...state, ...newState }
121    notify()
122  }
123
124  const fetchPage = async (skip: number) => {
125    if (state.hasInitialFetch && (state.isFetching || state.count <= state.data.length)) return
126
127    setState({ isFetching: true })
128
129    let query = supabase
130      .from(tableName)
131      .select(columns, { count: 'exact' }) as unknown as SupabaseSelectBuilder<T>
132
133    const trailingQuery = getTrailingQuery()
134    if (trailingQuery) {
135      query = trailingQuery(query)
136    }
137    const { data: newData, count, error } = await query.range(skip, skip + pageSize - 1)
138
139    if (error) {
140      console.error('An unexpected error occurred:', error)
141      setState({ error })
142    } else {
143      setState({
144        data: [...state.data, ...(newData as TData[])],
145        count: count || 0,
146        isSuccess: true,
147        error: null,
148      })
149    }
150    setState({ isFetching: false })
151  }
152
153  const fetchNextPage = async () => {
154    if (state.isFetching) return
155    await fetchPage(state.data.length)
156  }
157
158  const initialize = async () => {
159    setState({ isLoading: true, isSuccess: false, data: [] })
160    await fetchNextPage()
161    setState({ isLoading: false, hasInitialFetch: true })
162  }
163
164  return {
165    getState: () => state,
166    subscribe: (listener: Listener) => {
167      listeners.add(listener)
168      return () => listeners.delete(listener)
169    },
170    fetchNextPage,
171    initialize,
172  }
173}
174
175// Empty initial state to avoid hydration errors.
176const initialState: any = {
177  data: [],
178  count: 0,
179  isSuccess: false,
180  isLoading: false,
181  isFetching: false,
182  error: null,
183  hasInitialFetch: false,
184}
185
186function useInfiniteQuery<
187  TData extends SupabaseTableData<T>,
188  T extends SupabaseTableName = SupabaseTableName,
189>(props: UseInfiniteQueryProps<T>) {
190  const tableName = props.tableName
191  const columns = props.columns ?? '*'
192  const pageSize = props.pageSize ?? 20
193  const trailingQuery = props.trailingQuery
194  const trailingQueryKey = props.trailingQueryKey
195  const trailingQueryRef = useRef(trailingQuery)
196
197  trailingQueryRef.current = trailingQuery
198
199  const store = useMemo(
200    () =>
201      createStore<TData, T>({
202        tableName,
203        columns,
204        pageSize,
205        getTrailingQuery: () => trailingQueryRef.current,
206      }),
207    [tableName, columns, pageSize, trailingQueryKey]
208  )
209
210  const state = useSyncExternalStore(
211    store.subscribe,
212    () => store.getState(),
213    () => initialState as StoreState<TData>
214  )
215
216  useEffect(() => {
217    if (!state.hasInitialFetch && typeof window !== 'undefined') {
218      store.initialize()
219    }
220  }, [state.hasInitialFetch, store])
221
222  return {
223    data: state.data,
224    count: state.count,
225    isSuccess: state.isSuccess,
226    isLoading: state.isLoading,
227    isFetching: state.isFetching,
228    error: state.error,
229    hasMore: state.count > state.data.length,
230    fetchNextPage: store.fetchNextPage,
231  }
232}
233
234export {
235  useInfiniteQuery,
236  type SupabaseQueryHandler,
237  type SupabaseTableData,
238  type SupabaseTableName,
239  type UseInfiniteQueryProps,
240}

Introduction

The Infinite Query Hook provides a single React hook which will make it easier to load data progressively from your Supabase database. It handles data fetching and pagination state, It is meant to be used with infinite lists or tables. The hook is fully typed, provided you have generated and setup your database types.

Adding types

Before using this hook, we highly recommend you setup database types in your project. This will make the hook fully-typesafe. More info about generating Typescript types from database schema here

Props

PropTypeDescription
tableNamestringRequired. The name of the Supabase table to fetch data from.
columnsstringColumns to select from the table. Defaults to '*'.
pageSizenumberNumber of items to fetch per page. Defaults to 20.
trailingQuery(query: SupabaseSelectBuilder) => SupabaseSelectBuilderFunction to apply filters or sorting to the Supabase query.

Return type

data, count, isSuccess, isLoading, isFetching, error, hasMore, fetchNextPage

PropTypeDescription
dataTableData[]An array of fetched items.
countnumberNumber of total items in the database. It takes trailingQuery into consideration.
isSuccessbooleanIt's true if the last API call succeeded.
isLoadingbooleanIt's true only for the initial fetch.
isFetchingbooleanIt's true for the initial and all incremental fetches.
erroranyThe error from the last fetch.
hasMorebooleanWhether the query has finished fetching all items from the database
fetchNextPage() => voidSends a new request for the next items

Type safety

The hook will use the typed defined on your Supabase client if they're setup (more info).

The hook also supports an custom defined result type by using useInfiniteQuery<T>. For example, if you have a custom type for Product, you can use it like this useInfiniteQuery<Product>.

Usage

With sorting

const { data, fetchNextPage } = useInfiniteQuery({
  tableName: 'products',
  columns: '*',
  pageSize: 10,
  trailingQuery: (query) => query.order('created_at', { ascending: false }),
})
 
return (
  <div>
    {data.map((item) => (
      <ProductCard key={item.id} product={item} />
    ))}
    <Button onClick={fetchNextPage}>Load more products</Button>
  </div>
)

With filtering on search params

This example will filter based on a search param like example.com/?q=hello.

const params = useSearchParams()
const searchQuery = params.get('q')
 
const { data, isLoading, isFetching, fetchNextPage, count, isSuccess } = useInfiniteQuery({
  tableName: 'products',
  columns: '*',
  pageSize: 10,
  trailingQuery: (query) => {
    if (searchQuery && searchQuery.length > 0) {
      query = query.ilike('name', `%${searchQuery}%`)
    }
    return query
  },
})
 
return (
  <div>
    {data.map((item) => (
      <ProductCard key={item.id} product={item} />
    ))}
    <Button onClick={fetchNextPage}>Load more products</Button>
  </div>
)

Reusable components

Infinite list (fetches as you scroll)

The following component abstracts the hook into a component. It includes few utility components for no results and end of the list.

'use client'
 
import * as React from 'react'
 
import {
  SupabaseQueryHandler,
  SupabaseTableData,
  SupabaseTableName,
  useInfiniteQuery,
} from '@/hooks/use-infinite-query'
import { cn } from '@/lib/utils'
 
interface InfiniteListProps<TableName extends SupabaseTableName> {
  tableName: TableName
  columns?: string
  pageSize?: number
  trailingQuery?: SupabaseQueryHandler<TableName>
  renderItem: (item: SupabaseTableData<TableName>, index: number) => React.ReactNode
  className?: string
  renderNoResults?: () => React.ReactNode
  renderEndMessage?: () => React.ReactNode
  renderSkeleton?: (count: number) => React.ReactNode
}
 
const DefaultNoResults = () => (
  <div className="text-center text-muted-foreground py-10">No results.</div>
)
 
const DefaultEndMessage = () => (
  <div className="text-center text-muted-foreground py-4 text-sm">You&apos;ve reached the end.</div>
)
 
const defaultSkeleton = (count: number) => (
  <div className="flex flex-col gap-2 px-4">
    {Array.from({ length: count }).map((_, index) => (
      <div key={index} className="h-4 w-full bg-muted animate-pulse" />
    ))}
  </div>
)
 
export function InfiniteList<TableName extends SupabaseTableName>({
  tableName,
  columns = '*',
  pageSize = 20,
  trailingQuery,
  renderItem,
  className,
  renderNoResults = DefaultNoResults,
  renderEndMessage = DefaultEndMessage,
  renderSkeleton = defaultSkeleton,
}: InfiniteListProps<TableName>) {
  const { data, isFetching, hasMore, fetchNextPage, isSuccess } = useInfiniteQuery({
    tableName,
    columns,
    pageSize,
    trailingQuery,
  })
 
  // Ref for the scrolling container
  const scrollContainerRef = React.useRef<HTMLDivElement>(null)
 
  // Intersection observer logic - target the last rendered *item* or a dedicated sentinel
  const loadMoreSentinelRef = React.useRef<HTMLDivElement>(null)
  const observer = React.useRef<IntersectionObserver | null>(null)
 
  React.useEffect(() => {
    if (observer.current) observer.current.disconnect()
 
    observer.current = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasMore && !isFetching) {
          fetchNextPage()
        }
      },
      {
        root: scrollContainerRef.current, // Use the scroll container for scroll detection
        threshold: 0.1, // Trigger when 10% of the target is visible
        rootMargin: '0px 0px 100px 0px', // Trigger loading a bit before reaching the end
      }
    )
 
    if (loadMoreSentinelRef.current) {
      observer.current.observe(loadMoreSentinelRef.current)
    }
 
    return () => {
      if (observer.current) observer.current.disconnect()
    }
  }, [isFetching, hasMore, fetchNextPage])
 
  return (
    <div ref={scrollContainerRef} className={cn('relative h-full overflow-auto', className)}>
      <div>
        {isSuccess && data.length === 0 && renderNoResults()}
 
        {data.map((item, index) => renderItem(item, index))}
 
        {isFetching && renderSkeleton && renderSkeleton(pageSize)}
 
        <div ref={loadMoreSentinelRef} style={{ height: '1px' }} />
 
        {!hasMore && data.length > 0 && renderEndMessage()}
      </div>
    </div>
  )
}

Use the InfiniteList component with the Todo List quickstart.

Add <InfiniteListDemo /> to a page to see it in action. Ensure the Checkbox component from shadcn/ui is installed, and regenerate/download types after running the quickstart.

'use client'
 
import { InfiniteList } from './infinite-component'
import { Checkbox } from '@/components/ui/checkbox'
import { SupabaseQueryHandler } from '@/hooks/use-infinite-query'
import { Database } from '@/lib/supabase.types'
 
type TodoTask = Database['public']['Tables']['todos']['Row']
 
// Define how each item should be rendered
const renderTodoItem = (todo: TodoTask) => {
  return (
    <div
      key={todo.id}
      className="border-b py-3 px-4 hover:bg-muted flex items-center justify-between"
    >
      <div className="flex items-center gap-3">
        <Checkbox defaultChecked={todo.is_complete ?? false} />
        <div>
          <span className="font-medium text-sm text-foreground">{todo.task}</span>
          <div className="text-sm text-muted-foreground">
            {new Date(todo.inserted_at).toLocaleDateString()}
          </div>
        </div>
      </div>
    </div>
  )
}
 
const orderByInsertedAt: SupabaseQueryHandler<'todos'> = (query) => {
  return query.order('inserted_at', { ascending: false })
}
 
export const InfiniteListDemo = () => {
  return (
    <div className="bg-background h-[600px]">
      <InfiniteList
        tableName="todos"
        renderItem={renderTodoItem}
        pageSize={3}
        trailingQuery={orderByInsertedAt}
      />
    </div>
  )
}

Further reading