You have 100,000 orders to display. Fetch them all up front and you ship a multi-megabyte payload, freeze the main thread parsing JSON, and render tens of thousands of DOM nodes the user will never scroll to. The page feels broken before it even finishes loading.
The fix is to load data progressively. Instead of everything at once, you fetch a first slice, then load more as the user scrolls toward the bottom. Combined with row virtualization, the browser only ever holds a manageable number of rows in the DOM—so the grid stays smooth whether you have 200 rows or 2 million.
In this guide we cover three distinct patterns and exactly when to reach for each: client-side infinite scroll (append rows on scroll), server-side pagination (page-by-page), and lazy-loaded grouped children. Every example uses Simple Table, a free, source-available React data grid with these features built in.
See It In Action
Scroll to the bottom of the table below. As you approach the end, the grid calls onLoadMore, fetches the next batch, and appends it seamlessly—until there is no more data to load.
Infinite Scroll vs Server-Side Pagination
Both patterns avoid loading everything up front, but they create very different user experiences. Pick based on how your users work with the data.
Infinite Scroll
Append more rows as the user scrolls. One continuous list.
Best for
- • Browsing / exploration: feeds, logs, activity streams
- • Continuous scanning: users skim downward
- • Mobile-friendly UX: no tiny page controls to tap
Watch out
- • No stable "page 7": harder to deep-link a position
- • Memory grows: rows accumulate in state over time
Server-Side Pagination
Fetch one page at a time. The server owns slicing and the total count.
Best for
- • Record management: admin tables, dashboards
- • Shareable position: "page 7" is a real URL
- • Bounded memory: only the current page is held
Watch out
- • More clicks: users page through manually
- • Needs a total count: server must return it
Rule of thumb: use infinite scroll when users browse (feeds, logs, search results) and server-side pagination when users manage records (CRMs, admin panels). Both rely on row virtualization under the hood so the DOM stays small.
Infinite Scroll with onLoadMore
Simple Table fires onLoadMore when the user scrolls within infiniteScrollThreshold pixels of the bottom (200px by default). The table doesn't fetch or store data for you—you own that. Your job is three steps:
- Give the table a scroll container via
height(ormaxHeight), which also enables virtualization. - Implement
onLoadMoreto fetch the next batch. - Append the new rows to your existing array in state.
1import { SimpleTable, HeaderObject } from "@simple-table/react";2import { useState, useRef, useCallback } from "react";3import "@simple-table/react/styles.css";45const headers: HeaderObject[] = [6 { accessor: "id", label: "ID", width: 80, type: "number" },7 { accessor: "name", label: "Name", width: "1fr", type: "string" },8 { accessor: "email", label: "Email", width: "1fr", type: "string" },9 { accessor: "status", label: "Status", width: 120, type: "string" },10];1112export default function OrdersTable() {13 const [rows, setRows] = useState(() => fetchPage(0));14 const [loading, setLoading] = useState(false);15 const [hasMore, setHasMore] = useState(true);1617 // Synchronous re-entry guard. The `loading` state alone can't block18 // back-to-back calls: between setLoading(true) and React's next commit,19 // the callback the table holds still sees loading=false in its closure,20 // so multiple scroll ticks would slip through.21 const loadingRef = useRef(false);2223 const handleLoadMore = useCallback(async () => {24 if (loadingRef.current || !hasMore) return;25 loadingRef.current = true;26 setLoading(true);2728 try {29 const next = await fetchPageFromApi(/* offset */ undefined);30 if (next.length === 0) {31 setHasMore(false);32 return;33 }34 // Append to the live previous value so concurrent ticks can't duplicate.35 setRows((prev) => [...prev, ...next]);36 } finally {37 setLoading(false);38 loadingRef.current = false;39 }40 }, [hasMore]);4142 return (43 <div>44 <SimpleTable45 defaultHeaders={headers}46 rows={rows}47 height="600px" // scroll container + virtualization48 onLoadMore={handleLoadMore}49 infiniteScrollThreshold={300} // pre-fetch a little earlier50 />5152 {loading && <div className="py-2 text-center text-sm">Loading more…</div>}53 {!hasMore && <div className="py-2 text-center text-sm">All rows loaded</div>}54 </div>55 );56}
The two bugs everyone hits: (1) a stale closure lets multiple scroll ticks fire onLoadMore before your loading state commits—use a synchronous useRef guard. (2) Computing the next batch from a stale variable instead of the prev value inside setRows produces duplicate rows. Both are solved above.
Notice the loading spinner and the "all rows loaded" message live outside <SimpleTable>. The grid intentionally doesn't render this UI for you, so you keep full control over copy, placement, and skeletons.
Container Scroll vs Window Scroll
There are two ways to give the table a scroll context. They are mutually exclusive—and one of them is the most common real-app layout.
1. Inner (container) scroll with height
Set height or maxHeight and the table's own body scrolls inside that fixed box. This is what the example above uses. Best when the table sits in a panel or modal with a defined size.
2. Page scroll with scrollParent="window"
Want the table to grow to its natural height and let the page scroll—like a normal article section? Drop height/maxHeight and pass scrollParent. The window's scroll position then drives both virtualization and onLoadMore.
1// Page-level scroll (most common in real apps)2<SimpleTable3 defaultHeaders={headers}4 rows={rows}5 scrollParent="window"6 onLoadMore={handleLoadMore}7/>89// Or a custom overflow container (e.g. a side panel)10<SimpleTable11 defaultHeaders={headers}12 rows={rows}13 scrollParent={() => containerRef.current}14 onLoadMore={handleLoadMore}15/>
In window/external scroll mode the table also:
- Virtualizes against the parent—only rows in the parent's viewport render, even with tens of thousands of rows.
- Pins the header automatically with
position: stickyso it stays visible as you scroll. - Suppresses overscroll bounce on the scroll parent so the sticky header doesn't visually shift (restored on unmount).
- Honors
enableStickyParentsfor grouped rows, and reads the parent'spadding-topto pin the header flush.
Precedence & the no-virtualization trap
height/maxHeightalways win. If either is set,scrollParentis ignored.- With no
height/maxHeightand noscrollParent, the table renders every row—no virtualization andonLoadMorenever fires.
Server-Side Pagination (Lazy Loading by Page)
When users need stable pages instead of an endless list, use server-side pagination. Set serverSidePagination so the table stops slicing rows internally—you supply exactly one page of rows, and the table renders the footer controls and tells you when the page changes via onPageChange. Provide totalRowCount so it can compute the number of pages.
1import { SimpleTable, HeaderObject } from "@simple-table/react";2import { useState, useEffect } from "react";3import "@simple-table/react/styles.css";45const PAGE_SIZE = 50;67export default function ServerPaginatedTable() {8 const [rows, setRows] = useState([]);9 const [total, setTotal] = useState(0);10 const [page, setPage] = useState(0);11 const [loading, setLoading] = useState(false);1213 useEffect(() => {14 let cancelled = false;15 setLoading(true);16 fetch(`/api/orders?page=${page}&limit=${PAGE_SIZE}`)17 .then((res) => res.json())18 .then((data) => {19 if (cancelled) return;20 setRows(data.rows);21 setTotal(data.total);22 })23 .finally(() => !cancelled && setLoading(false));24 return () => {25 cancelled = true;26 };27 }, [page]);2829 return (30 <SimpleTable31 defaultHeaders={headers}32 rows={rows}33 height="600px"34 shouldPaginate35 serverSidePagination // don't slice rows internally36 rowsPerPage={PAGE_SIZE}37 totalRowCount={total} // lets the footer compute page count38 isLoading={loading} // built-in skeleton during fetches39 onPageChange={(nextPage) => setPage(nextPage)}40 />41 );42}
Key props: serverSidePagination disables internal slicing, onPageChange fires on navigation (it can return a promise), totalRowCount drives the page count, and isLoading shows the built-in skeleton state while each page loads.
Want fully custom footer UI instead of the built-in controls? See the pagination docs for the footerRenderer escape hatch.
Why Virtualization Makes This Scale
Infinite scroll alone isn't enough. If you append 50,000 rows and render every one, the DOM still grinds to a halt. The reason Simple Table stays smooth is row virtualization: only the rows visible in the scroll viewport (plus a small buffer) are mounted. Scroll down and rows are recycled, so the DOM node count stays roughly constant no matter how much data you load.
Virtualization turns on automatically the moment you give the table a scroll context—height, maxHeight, or scrollParent. That's the same prop that enables infinite scroll, so you get both together. To see how far this scales, read Handling 1,000,000 Rows with Simple Table.
Need a refresher on sizing the scroll container? The table height docs cover fixed vs adaptive heights.
Key Takeaways
- Infinite scroll: implement
onLoadMore, append torows, and tuneinfiniteScrollThresholdfor earlier pre-fetching. - Guard against duplicates: use a synchronous
useRefre-entry guard and append from theprevvalue. - Pick a scroll mode:
heightfor container scroll orscrollParent="window"for page scroll—never expect infinite scroll without one of them. - Server-side pagination via
serverSidePagination,onPageChange,totalRowCount, andisLoadingwhen users need stable pages. - Virtualization is automatic with any scroll context and is what keeps the grid fast as data grows.
Whether you're building activity feeds, log viewers, or admin dashboards, Simple Table gives you progressive loading and virtualization for free—no enterprise license required.