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

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 { cn } from '@/lib/utils'
import {
  SupabaseQueryHandler,
  SupabaseTableData,
  SupabaseTableName,
  useInfiniteQuery,
} from '@/hooks/use-infinite-query'
import * as React from 'react'
 
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 { Checkbox } from '@/components/ui/checkbox'
import { InfiniteList } from './infinite-component'
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