mirror of
https://github.com/supabase/supabase.git
synced 2026-06-02 19:02:06 +08:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? This PR is the final PR for Supabase UI Vue&Nuxt with the Realtime Chat and Infinite Query. ## Additional context Initiative by Terry <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes * **New Features** * Realtime Chat now available for Vue and Nuxt.js frameworks with full documentation and composables * Added Infinite Query composable for Vue with comprehensive guides * **Documentation** * New Realtime Chat documentation pages for Vue and Nuxt.js * New Infinite Query documentation for Vue * Updated framework support in documentation navigation <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Terry Sutton <saltcod@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
292 lines
10 KiB
Plaintext
292 lines
10 KiB
Plaintext
---
|
|
title: Infinite Query Hook
|
|
description: React hook for infinite lists, fetching data from Supabase.
|
|
---
|
|
|
|
<BlockPreview name="infinite-list-demo" />
|
|
|
|
## Installation
|
|
|
|
<BlockItem
|
|
name="infinite-query-hook"
|
|
description="Installs the Infinite List component and necessary Supabase client setup."
|
|
/>
|
|
|
|
## Folder structure
|
|
|
|
<RegistryBlock itemName="infinite-query-hook" />
|
|
|
|
## 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](https://supabase.com/docs/guides/api/rest/generating-types)
|
|
|
|
## 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](https://supabase.com/docs/reference/javascript/typescript-support)).
|
|
|
|
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
|
|
|
|
```tsx
|
|
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`.
|
|
|
|
```tsx
|
|
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.
|
|
|
|
```tsx
|
|
'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](https://supabase.com/dashboard/project/_/sql/quickstarts) quickstart.
|
|
|
|
Add `<InfiniteListDemo />` to a page to see it in action.
|
|
Ensure the [Checkbox](https://ui.shadcn.com/docs/components/checkbox) component from shadcn/ui is installed, and [regenerate/download](https://supabase.com/docs/guides/api/rest/generating-types) types after running the quickstart.
|
|
|
|
```tsx
|
|
'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>
|
|
)
|
|
}
|
|
```
|
|
|
|
<Callout>
|
|
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](https://supabase.com/docs/guides/troubleshooting/why-is-my-select-returning-an-empty-data-array-and-i-have-data-in-the-table-xvOPgx)
|
|
of results by default. [Read
|
|
more](https://supabase.com/docs/guides/database/postgres/row-level-security) about RLS.
|
|
</Callout>
|
|
|
|
## Further reading
|
|
|
|
- [Generating Typescript types from the database](https://supabase.com/docs/reference/javascript/typescript-support)
|
|
- [Supabase Database API](https://supabase.com/docs/reference/javascript/select)
|
|
- [Supabase Pagination](https://supabase.com/docs/reference/javascript/select#pagination)
|
|
- [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API)
|