Infinite Query Hook
React hook for infinite lists, fetching data from Supabase.
Installation
Folder structure
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
| 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 { 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'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>
)
}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.