Infinite Scroll Table
Load more rows on demand as the user scrolls — perfect for API-backed tables with paginated endpoints.
Preview with Controlled State
View Full State Object
[]
{}{
"pageIndex": 0,
"pageSize": 500
}{}{
"left": [],
"right": []
}Introduction
Section titled “Introduction”The Infinite Scroll Table loads additional rows on demand as the user scrolls toward the bottom of the table — the standard UX for cursor-paginated APIs, activity feeds, and long product lists where loading everything upfront would be wasteful.
Niko Table ships the scroll trigger (onScrolledBottom on DataTableBody) and the composable “loading more” indicator (DataTableLoadingMore) as first-class primitives, so wiring infinite scroll is just: drop one child into the body, wire one callback, done.
This example uses a mocked in-memory API with artificial latency so you can see every state — initial skeleton, successive next-page spinner rows, and the end-of-results sentinel — without touching a real backend.
Installation
Section titled “Installation”Install the DataTable core and add-ons for this example:
First time using
@niko-table? See the Installation Guide to set up the registry.
Prerequisites
Section titled “Prerequisites”We are going to build a table that paginates through a list of products. Here’s what our data looks like:
type Product = { id: string name: string category: string price: number stock: number status: "in-stock" | "low-stock" | "out-of-stock"}Implementation
Section titled “Implementation”Deterministic mock data generator
Section titled “Deterministic mock data generator”One important constraint when building demos: don’t use Math.random() or Date.now() in your mock data. If a consumer copies this file into a Next.js / Remix / RSC app, those non-deterministic calls produce different values on the server render vs the client hydration — and React will log a hydration mismatch on every row. Use index-based deterministic values instead:
const CATEGORIES = [ "Electronics", "Clothing", "Food", "Books", "Sports", "Home", "Toys", "Beauty",] as const
function generateMockProducts(count: number): Product[] { return Array.from({ length: count }, (_, i) => { const stock = (i * 37) % 150 const price = ((i * 13) % 490) + 10 return { id: `product-${i + 1}`, name: `Product ${i + 1}`, category: CATEGORIES[i % CATEGORIES.length], price, stock, status: stock === 0 ? "out-of-stock" : stock < 20 ? "low-stock" : "in-stock", } })}
// The "server" — a 500-item pool we paginate through.const TOTAL_POOL = generateMockProducts(500)const PAGE_SIZE = 20const FAKE_LATENCY_MS = 800
// Simulate a paginated API endpoint.function fetchNextPage(offset: number): Promise<Product[]> { return new Promise(resolve => { setTimeout(() => { resolve(TOTAL_POOL.slice(offset, offset + PAGE_SIZE)) }, FAKE_LATENCY_MS) })}When you swap this out for a real API call (TanStack Query’s useInfiniteQuery, SWR’s useSWRInfinite, tRPC, a plain fetch, etc.) the hydration concern disappears — real API data is already stable.
Pagination state + loadMore handler
Section titled “Pagination state + loadMore handler”The component tracks three pieces of state:
loaded— the accumulator of rows fetched so far.isLoading— true during the very first fetch (drivesDataTableSkeleton).isFetching— true during any subsequent next-page fetch (drivesDataTableLoadingMore).
const [loaded, setLoaded] = React.useState<Product[]>([])const [isLoading, setIsLoading] = React.useState(true)const [isFetching, setIsFetching] = React.useState(false)
// Derive "is there more to fetch?" from the accumulator size.const hasMore = loaded.length < TOTAL_POOL.length
// Kick off the first page on mount.React.useEffect(() => { let cancelled = false void fetchNextPage(0).then(page => { if (cancelled) return setLoaded(page) setIsLoading(false) }) return () => { cancelled = true }}, [])
const loadMore = React.useCallback(async () => { // Double-guard: never fire while a fetch is in flight, never // fire when the pool is exhausted. if (isFetching || !hasMore) return setIsFetching(true) try { const nextPage = await fetchNextPage(loaded.length) setLoaded(prev => [...prev, ...nextPage]) } finally { setIsFetching(false) }}, [isFetching, hasMore, loaded.length])<DataTableBody /> with onScrolledBottom + <DataTableLoadingMore />
Section titled “<DataTableBody /> with onScrolledBottom + <DataTableLoadingMore />”The magic happens here. DataTableBody fires onScrolledBottom when the user scrolls within scrollThreshold pixels of the bottom. DataTableLoadingMore is a composable child that self-gates on its own isFetching prop — render it unconditionally, and it appears/disappears as fetches come and go.
"use client"
import * as React from "react"import { DataTableRoot } from "@/components/niko-table/core/data-table-root"import { DataTable } from "@/components/niko-table/core/data-table"import { DataTableHeader, DataTableBody, DataTableEmptyBody, DataTableSkeleton, DataTableLoadingMore,} from "@/components/niko-table/core/data-table-structure"import { DataTableSearchFilter } from "@/components/niko-table/components/data-table-search-filter"import { DataTableToolbarSection } from "@/components/niko-table/components/data-table-toolbar-section"import { DataTableViewMenu } from "@/components/niko-table/components/data-table-view-menu"
export function InfiniteScrollTable() { // ... state + loadMore from the previous section ...
return ( <DataTableRoot data={loaded} columns={columns} isLoading={isLoading} config={{ initialPageSize: 500 }} > <DataTableToolbarSection> <DataTableSearchFilter placeholder="Search products..." /> <DataTableViewMenu /> </DataTableToolbarSection>
<DataTable className="max-h-[600px] rounded-lg border"> <DataTableHeader /> <DataTableBody scrollThreshold={200} onScrolledBottom={() => { if (hasMore && !isFetching) void loadMore() }} > <DataTableSkeleton rows={10} /> <DataTableEmptyBody /> <DataTableLoadingMore isFetching={isFetching}> Loading more products... </DataTableLoadingMore> </DataTableBody> </DataTable> </DataTableRoot> )}Required: Fixed Height
Section titled “Required: Fixed Height”Infinite scroll needs a scroll container. Without a fixed max-h-* (or height) on <DataTable>, the table grows with its content and there’s no overflow to scroll — which means onScrolledBottom will never fire and your loadMore handler will silently do nothing.
<DataTable className="max-h-[600px] rounded-lg border"> {/* ... */}</DataTable>Guard Against Double-Fetches
Section titled “Guard Against Double-Fetches”Always guard your loadMore handler against re-entry. Scroll events can fire in rapid bursts (especially on momentum scroll on mobile), so even a well-placed onScrolledBottom can be triggered multiple times before React has a chance to flip isFetching to true.
onScrolledBottom={() => { if (hasMore && !isFetching) void loadMore()}}
const loadMore = React.useCallback(async () => { // Double-guard inside the handler itself. if (isFetching || !hasMore) return // ...}, [isFetching, hasMore])When to Use
Section titled “When to Use”✅ Use this non-virtualized variant when:
- Your total dataset is small (under ~200 rows) and virtualization is overkill
- You need variable row heights that don’t work well with
estimateSize - You want the simplest possible setup with no
@tanstack/react-virtualdependency
⚠️ For most infinite-scroll use cases, prefer the Infinite Scroll Virtualized Table instead. It renders only visible rows regardless of how many are loaded, so performance stays constant even after accumulating thousands of rows.
❌ Don’t use Infinite Scroll Table when:
- You need client-side global sorting or filtering across the entire dataset (you can’t sort what hasn’t been fetched yet — prefer server-side sort/filter, or fetch all rows upfront)
- Users need to bookmark or deep-link to a specific row (infinite scroll has no stable page URLs)
- The dataset is small enough to fit in one round-trip (just use Basic Table)
Next Steps
Section titled “Next Steps”- Virtualization Table — for large datasets already fully loaded
- Infinite Scroll Virtualized Table — combine both for massive paginated datasets
- Server-Side Table — when you need server-driven sort/filter with traditional pagination