React Table Infinite Scroll & Lazy Loading: Complete Guide

Infinite ScrollPerformanceTutorial

Loading 50,000 rows at once kills performance and overwhelms users. Learn how to load data progressively in React data grids—infinite scroll with onLoadMore, window vs container scrolling, row virtualization, and server-side pagination—with production-ready code.

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:

  1. Give the table a scroll container via height (or maxHeight), which also enables virtualization.
  2. Implement onLoadMore to fetch the next batch.
  3. Append the new rows to your existing array in state.
React TSX
1import { SimpleTable, HeaderObject } from "@simple-table/react";
2import { useState, useRef, useCallback } from "react";
3import "@simple-table/react/styles.css";
4
5const 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];
11
12export default function OrdersTable() {
13 const [rows, setRows] = useState(() => fetchPage(0));
14 const [loading, setLoading] = useState(false);
15 const [hasMore, setHasMore] = useState(true);
16
17 // Synchronous re-entry guard. The `loading` state alone can't block
18 // 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);
22
23 const handleLoadMore = useCallback(async () => {
24 if (loadingRef.current || !hasMore) return;
25 loadingRef.current = true;
26 setLoading(true);
27
28 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]);
41
42 return (
43 <div>
44 <SimpleTable
45 defaultHeaders={headers}
46 rows={rows}
47 height="600px" // scroll container + virtualization
48 onLoadMore={handleLoadMore}
49 infiniteScrollThreshold={300} // pre-fetch a little earlier
50 />
51
52 {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.

React TSX
1// Page-level scroll (most common in real apps)
2<SimpleTable
3 defaultHeaders={headers}
4 rows={rows}
5 scrollParent="window"
6 onLoadMore={handleLoadMore}
7/>
8
9// Or a custom overflow container (e.g. a side panel)
10<SimpleTable
11 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: sticky so 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 enableStickyParents for grouped rows, and reads the parent's padding-top to pin the header flush.

Precedence & the no-virtualization trap

  • height/maxHeight always win. If either is set, scrollParent is ignored.
  • With no height/maxHeight and no scrollParent, the table renders every row—no virtualization and onLoadMore never 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.

React TSX
1import { SimpleTable, HeaderObject } from "@simple-table/react";
2import { useState, useEffect } from "react";
3import "@simple-table/react/styles.css";
4
5const PAGE_SIZE = 50;
6
7export 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);
12
13 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]);
28
29 return (
30 <SimpleTable
31 defaultHeaders={headers}
32 rows={rows}
33 height="600px"
34 shouldPaginate
35 serverSidePagination // don't slice rows internally
36 rowsPerPage={PAGE_SIZE}
37 totalRowCount={total} // lets the footer compute page count
38 isLoading={loading} // built-in skeleton during fetches
39 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 to rows, and tune infiniteScrollThreshold for earlier pre-fetching.
  • Guard against duplicates: use a synchronous useRef re-entry guard and append from the prev value.
  • Pick a scroll mode: height for container scroll or scrollParent="window" for page scroll—never expect infinite scroll without one of them.
  • Server-side pagination via serverSidePagination, onPageChange, totalRowCount, and isLoading when 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.

Ready to load massive datasets without the lag?

Simple Table ships infinite scroll, server-side pagination, and row virtualization out of the box. Start building fast, progressively-loaded React tables in minutes.