Lazy loading with pagination
In this tutorial, you will load grid data page by page as the user scrolls to the bottom of the grid. You will learn how to use the afterScrollVertically hook and hot.updateData() to append new rows without resetting the scroll position or grid state.
import { useRef, useState, useCallback, useEffect } from 'react';import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
/* start:skip-in-preview */const INITIAL_DATA = [ { id: 1, title: 'Set up CI pipeline', completed: false, userId: 3 }, { id: 2, title: 'Write unit tests for auth module', completed: true, userId: 1 }, { id: 3, title: 'Review pull request #42', completed: false, userId: 2 }, { id: 4, title: 'Update API documentation', completed: true, userId: 1 }, { id: 5, title: 'Fix login redirect bug', completed: true, userId: 4 }, { id: 6, title: 'Deploy staging environment', completed: false, userId: 3 }, { id: 7, title: 'Implement dark mode toggle', completed: false, userId: 2 }, { id: 8, title: 'Optimize database queries', completed: true, userId: 5 }, { id: 9, title: 'Add error boundary components', completed: false, userId: 1 }, { id: 10, title: 'Migrate to TypeScript', completed: false, userId: 2 }, { id: 11, title: 'Set up monitoring alerts', completed: true, userId: 3 }, { id: 12, title: 'Refactor form validation logic', completed: false, userId: 4 }, { id: 13, title: 'Add keyboard shortcuts guide', completed: true, userId: 1 }, { id: 14, title: 'Review security audit findings', completed: false, userId: 5 }, { id: 15, title: 'Create onboarding checklist', completed: true, userId: 2 }, { id: 16, title: 'Update dependencies to latest', completed: false, userId: 3 }, { id: 17, title: 'Write release notes for v2.0', completed: false, userId: 1 }, { id: 18, title: 'Set up feature flags service', completed: true, userId: 4 }, { id: 19, title: 'Implement CSV export feature', completed: false, userId: 2 }, { id: 20, title: 'Add pagination to task list', completed: true, userId: 5 },];/* end:skip-in-preview */
const LOAD_THRESHOLD = 5;const PAGE_SIZE = 20;// JSONPlaceholder has 200 todos total (10 pages of 20)const TOTAL_PAGES = 10;
const ExampleComponent = () => { const hotRef = useRef(null); const currentPage = useRef(1); const isLoading = useRef(false); const hasMore = useRef(true); const loadedData = useRef([...INITIAL_DATA]); // Always visible -- no blinking; text only changes at end-of-data or on error const [statusText, setStatusText] = useState('Scroll table to load more records');
const fetchNextPage = useCallback(async () => { if (isLoading.current || !hasMore.current) { return; }
isLoading.current = true; hotRef.current?.hotInstance?.getPlugin('loading').show();
try { const nextPage = currentPage.current + 1; const response = await fetch( `https://jsonplaceholder.typicode.com/todos?_page=${nextPage}&_limit=${PAGE_SIZE}` );
if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); }
const newRows = await response.json();
if (newRows.length === 0 || nextPage >= TOTAL_PAGES) { hasMore.current = false; setStatusText('All tasks loaded.'); } else { currentPage.current = nextPage; loadedData.current = [...loadedData.current, ...newRows]; // `updateData()` appends rows without resetting scroll position hotRef.current?.hotInstance?.updateData(loadedData.current); } } catch { setStatusText('Failed to load more tasks. Scroll down to retry.'); isLoading.current = false; hotRef.current?.hotInstance?.getPlugin('loading').hide();
return; }
isLoading.current = false; hotRef.current?.hotInstance?.getPlugin('loading').hide(); }, []);
useEffect(() => { fetchNextPage(); }, [fetchNextPage]);
return ( <> <HotTable ref={hotRef} data={INITIAL_DATA} colHeaders={['Task Title', 'Status', 'Assignee']} columns={[ { data: 'title', type: 'text', width: 400 }, { data: 'completed', type: 'checkbox', width: 80, className: 'htCenter' }, { data: 'userId', type: 'numeric', width: 100 }, ]} rowHeaders={true} height={400} width="100%" stretchH="all" autoWrapRow={true} loading={true} afterScrollVertically={function () { const lastVisibleRow = this.view.getLastFullyVisibleRow(); const totalRows = this.countRows();
if (lastVisibleRow >= 0 && lastVisibleRow >= totalRows - LOAD_THRESHOLD) { fetchNextPage(); } }} licenseKey="non-commercial-and-evaluation" /> <div style={{ padding: '8px', textAlign: 'center', color: '#666', fontSize: '13px' }}> {statusText} </div> </> );};
export default ExampleComponent;import { useRef, useState, useCallback, useEffect } from 'react';import { HotTable, HotTableRef } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
/* start:skip-in-preview */type TaskRow = { id: number; title: string; completed: boolean; userId: number;};
const INITIAL_DATA: TaskRow[] = [ { id: 1, title: 'Set up CI pipeline', completed: false, userId: 3 }, { id: 2, title: 'Write unit tests for auth module', completed: true, userId: 1 }, { id: 3, title: 'Review pull request #42', completed: false, userId: 2 }, { id: 4, title: 'Update API documentation', completed: true, userId: 1 }, { id: 5, title: 'Fix login redirect bug', completed: true, userId: 4 }, { id: 6, title: 'Deploy staging environment', completed: false, userId: 3 }, { id: 7, title: 'Implement dark mode toggle', completed: false, userId: 2 }, { id: 8, title: 'Optimize database queries', completed: true, userId: 5 }, { id: 9, title: 'Add error boundary components', completed: false, userId: 1 }, { id: 10, title: 'Migrate to TypeScript', completed: false, userId: 2 }, { id: 11, title: 'Set up monitoring alerts', completed: true, userId: 3 }, { id: 12, title: 'Refactor form validation logic', completed: false, userId: 4 }, { id: 13, title: 'Add keyboard shortcuts guide', completed: true, userId: 1 }, { id: 14, title: 'Review security audit findings', completed: false, userId: 5 }, { id: 15, title: 'Create onboarding checklist', completed: true, userId: 2 }, { id: 16, title: 'Update dependencies to latest', completed: false, userId: 3 }, { id: 17, title: 'Write release notes for v2.0', completed: false, userId: 1 }, { id: 18, title: 'Set up feature flags service', completed: true, userId: 4 }, { id: 19, title: 'Implement CSV export feature', completed: false, userId: 2 }, { id: 20, title: 'Add pagination to task list', completed: true, userId: 5 },];/* end:skip-in-preview */
const LOAD_THRESHOLD = 5;const PAGE_SIZE = 20;// JSONPlaceholder has 200 todos total (10 pages of 20)const TOTAL_PAGES = 10;
const ExampleComponent = () => { const hotRef = useRef<HotTableRef>(null); const currentPage = useRef(1); const isLoading = useRef(false); const hasMore = useRef(true); const loadedData = useRef<TaskRow[]>([...INITIAL_DATA]); // Always visible -- no blinking; text only changes at end-of-data or on error const [statusText, setStatusText] = useState('Scroll table to load more records');
const fetchNextPage = useCallback(async (): Promise<void> => { if (isLoading.current || !hasMore.current) { return; }
isLoading.current = true; hotRef.current?.hotInstance?.getPlugin('loading').show();
try { const nextPage = currentPage.current + 1; const response = await fetch( `https://jsonplaceholder.typicode.com/todos?_page=${nextPage}&_limit=${PAGE_SIZE}` );
if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); }
const newRows: TaskRow[] = await response.json();
if (newRows.length === 0 || nextPage >= TOTAL_PAGES) { hasMore.current = false; setStatusText('All tasks loaded.'); } else { currentPage.current = nextPage; loadedData.current = [...loadedData.current, ...newRows]; // `updateData()` appends rows without resetting scroll position hotRef.current?.hotInstance?.updateData(loadedData.current); } } catch { setStatusText('Failed to load more tasks. Scroll down to retry.'); isLoading.current = false; hotRef.current?.hotInstance?.getPlugin('loading').hide();
return; }
isLoading.current = false; hotRef.current?.hotInstance?.getPlugin('loading').hide(); }, []);
useEffect(() => { fetchNextPage(); }, [fetchNextPage]);
return ( <> <HotTable ref={hotRef} data={INITIAL_DATA} colHeaders={['Task Title', 'Status', 'Assignee']} columns={[ { data: 'title', type: 'text', width: 400 }, { data: 'completed', type: 'checkbox', width: 80, className: 'htCenter' }, { data: 'userId', type: 'numeric', width: 100 }, ]} rowHeaders={true} height={400} width="100%" stretchH="all" autoWrapRow={true} loading={true} afterScrollVertically={function (this: Handsontable) { const lastVisibleRow = this.view.getLastFullyVisibleRow(); const totalRows = this.countRows();
if (lastVisibleRow >= 0 && lastVisibleRow >= totalRows - LOAD_THRESHOLD) { fetchNextPage(); } }} licenseKey="non-commercial-and-evaluation" /> <div style={{ padding: '8px', textAlign: 'center', color: '#666', fontSize: '13px' }}> {statusText} </div> </> );};
export default ExampleComponent;Overview
Difficulty: Intermediate Time: ~25 minutes
This tutorial shows how to load data in pages as the user scrolls toward the bottom of a Handsontable grid. The grid starts with an initial data set and silently fetches the next page when the user approaches the last visible row — without resetting the scroll position, selection, or column configuration.
This pattern is useful when working with large remote data sets where loading everything upfront would be too slow or memory-intensive.
What You’ll Build
A task-list grid that:
- Displays an initial page of project tasks on load
- Detects when the user scrolls near the last row using
afterScrollVertically - Fetches the next page from a paginated REST API
- Appends the new rows with
hot.updateData()— preserving scroll position - Shows a loading indicator while the fetch is in progress
- Stops fetching once all pages have been loaded
Before you begin
- You need a working Handsontable installation. See the Getting started guide.
- This example fetches data from JSONPlaceholder, a free public API. An active internet connection is required to run the live demo. The grid falls back to the built-in
INITIAL_DATAarray when the network is unavailable. - Familiarity with
async/awaitand the browserfetchAPI is helpful.
Step 1 — Set up state and constants
The example tracks three flags that control fetching behavior:
let currentPage = 1;let isLoading = false;let hasMore = true;currentPage— the last page that was successfully loaded. Increments after each successful fetch.isLoading— prevents duplicate requests. Set totrueat the start of a fetch and back tofalsewhen it completes.hasMore— set tofalsewhen the API returns an empty result or the last page is reached. Stops any further fetch attempts.
You also define two constants that control fetch timing:
const LOAD_THRESHOLD = 5;const PAGE_SIZE = 20;LOAD_THRESHOLD is the number of rows from the bottom of the loaded data that triggers the next fetch. A value of 5 means the fetch starts when the user is 5 rows away from the last row — giving the network request time to complete before the user reaches the end.
Step 2 — Create the loading indicator
The loading indicator is created in JavaScript and inserted below the grid container:
const loadingIndicator = document.createElement('div');
loadingIndicator.style.cssText = 'display:none; padding:8px; text-align:center; color:#666; font-size:13px;';loadingIndicator.textContent = 'Loading more tasks...';container.insertAdjacentElement('afterend', loadingIndicator);You show it at the start of a fetch and hide it when the fetch completes. When all data is loaded, you update its text to “All tasks loaded.” and leave it visible so the user knows there is nothing more to fetch.
Step 3 — Write the fetchNextPage function
This function is the core of the recipe. It guards against duplicate calls, fetches the next page, and appends the rows:
async function fetchNextPage() { if (isLoading || !hasMore) { return; }
isLoading = true; loadingIndicator.style.display = 'block';
try { const nextPage = currentPage + 1; const response = await fetch( `https://jsonplaceholder.typicode.com/todos?_page=${nextPage}&_limit=${PAGE_SIZE}` );
if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); }
const newRows = await response.json();
if (newRows.length === 0 || nextPage >= TOTAL_PAGES) { hasMore = false; loadingIndicator.textContent = 'All tasks loaded.'; loadingIndicator.style.display = 'block'; } else { currentPage = nextPage; loadedData = [...loadedData, ...newRows]; hot.updateData(loadedData); } } catch { loadingIndicator.textContent = 'Failed to load more tasks. Scroll down to retry.'; loadingIndicator.style.display = 'block'; isLoading = false;
return; }
isLoading = false;
if (hasMore) { loadingIndicator.style.display = 'none'; }}What’s happening:
- The
if (isLoading || !hasMore)guard at the top ensures the function returns immediately if a request is already in flight or no more data exists. This prevents duplicate requests caused by fast scrolling. fetch()requests the next page using_pageand_limitquery parameters — a standard pagination pattern supported by most REST APIs.- When the response arrives,
loadedDatais updated using the spread operator to create a new array:[...loadedData, ...newRows]. This is required becauseupdateData()expects a new array reference to detect the change. hot.updateData(loadedData)replaces the grid’s data source. UnlikeloadData(),updateData()does not reset scroll position, selection state, or column configuration — the user sees the new rows appended at the bottom without any visual jump.- Errors are caught and shown in the indicator.
isLoadingis reset tofalseso the user can retry by scrolling again.
Why updateData() and not loadData()?
loadData() resets the entire grid state — it scrolls back to the top, clears the selection, and re-renders from scratch. That would break the scrolling experience. updateData() performs a diff and only updates what changed, leaving the viewport position intact.
Step 4 — Initialize the grid and attach the scroll hook
Create the grid with the initial data, then use afterScrollVertically to detect proximity to the last row:
const hot = new Handsontable(container, { data: loadedData, colHeaders: ['ID', 'Task Title', 'Status', 'Assignee'], columns: [ { data: 'id', type: 'numeric', width: 60, readOnly: true }, { data: 'title', type: 'text', width: 340 }, { data: 'completed', type: 'checkbox', width: 80, className: 'htCenter' }, { data: 'userId', type: 'numeric', width: 100 }, ], rowHeaders: true, height: 400, afterScrollVertically() { const lastVisibleRow = this.view.getLastFullyVisibleRow(); const totalRows = this.countRows();
if (lastVisibleRow >= 0 && lastVisibleRow >= totalRows - LOAD_THRESHOLD) { fetchNextPage(); } }, licenseKey: 'non-commercial-and-evaluation',});What’s happening:
afterScrollVerticallyfires after every vertical scroll event. Inside the callback,thisrefers to the Handsontable instance.this.view.getLastFullyVisibleRow()returns the visual index of the last row that is fully visible in the viewport. It returns-1when no rows are visible, so the>= 0check prevents triggering a fetch during the initial render.this.countRows()returns the total number of rows currently loaded in the grid.- When
lastVisibleRow >= totalRows - LOAD_THRESHOLD, the user is withinLOAD_THRESHOLDrows of the bottom. This triggersfetchNextPage(), which will be a no-op ifisLoadingis alreadytrue.
Why use afterScrollVertically instead of a scroll event listener?
Attaching a native scroll event listener to the grid container requires knowing the internal scroll element, which is an implementation detail that can change. afterScrollVertically is a stable public API that fires at the right time, after Handsontable has updated the viewport.
Step 5 — Load the first remote page on init
After creating the grid, call fetchNextPage() once to replace the built-in INITIAL_DATA with live server data:
fetchNextPage();This runs immediately after the grid initializes. The grid displays INITIAL_DATA for a moment while the first network request is in flight, then updateData() replaces it with the server response. If the network is unavailable, the grid keeps showing INITIAL_DATA.
How It Works - Complete Flow
- Grid initializes with
INITIAL_DATA(20 local rows) andfetchNextPage()runs immediately. - First remote fetch loads page 2 from the API.
updateData()replacesINITIAL_DATAwith 20 server rows.currentPagebecomes 2. - User scrolls down.
afterScrollVerticallyfires on every scroll event. WhenlastVisibleRow >= totalRows - 5,fetchNextPage()is called. - Guard check: if
isLoadingistrue(a fetch is already in flight), the function returns immediately. This prevents duplicate requests no matter how fast the user scrolls. - Fetch runs:
isLoadingbecomestrue, the loading indicator appears, and the next page is fetched. - Response arrives: new rows are spread into
loadedData,updateData()appends them to the grid, scroll position is unchanged, andisLoadingresets tofalse. - End of data: when
nextPage >= TOTAL_PAGESor the response is empty,hasMorebecomesfalse. The loading indicator shows “All tasks loaded.” and all futurefetchNextPage()calls return immediately.
What you learned
- How to use
afterScrollVerticallyto detect when the user is near the bottom of loaded data. - Why
hot.updateData()is the correct method for appending rows — it preserves scroll position and column state, unlikehot.loadData(). - How to use
isLoading,hasMore, andcurrentPageflags to prevent duplicate requests and handle end-of-data gracefully. - How to show and update a loading indicator during an async fetch.
Next steps
- Replace the JSONPlaceholder API with your own paginated endpoint. Most REST APIs support
_page/_limitoroffset/limitstyle pagination. - Add column sorting or filtering. When the user sorts a column, you may want to reset
currentPageandloadedDataand reload from page 1 with the sort parameters forwarded to the API. - Combine with the DataProvider plugin for a fully managed server-backed data grid with built-in loading states and error handling.