Infinite Query Hook
React hook for infinite lists, fetching data from Supabase.
Installation
Folder structure
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
| Prop | Type | Description |
|---|---|---|
tableName | string | Required. The name of the Supabase table to fetch data from. |
columns | string | Columns to select from the table. Defaults to '*'. |
pageSize | number | Number of items to fetch per page. Defaults to 20. |
trailingQuery | (query: SupabaseSelectBuilder) => SupabaseSelectBuilder | Function to apply filters or sorting to the Supabase query. |
Return type
data, count, isSuccess, isLoading, isFetching, error, hasMore, fetchNextPage
| Prop | Type | Description |
|---|---|---|
data | TableData[] | An array of fetched items. |
count | number | Number of total items in the database. It takes trailingQuery into consideration. |
isSuccess | boolean | It's true if the last API call succeeded. |
isLoading | boolean | It's true only for the initial fetch. |
isFetching | boolean | It's true for the initial and all incremental fetches. |
error | any | The error from the last fetch. |
hasMore | boolean | Whether the query has finished fetching all items from the database |
fetchNextPage | () => void | Sends 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'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>
)
}The Todo List table has Row Level Security (RLS) enabled by default. Feel free disable it temporarily while testing. With RLS enabled, you will get an empty array of results by default. Read more about RLS.