CHRIS RAO - DEVELOPER

TFW the Feature You Want Isn't Free

Say you really want to use server side pagination with AG React Data Grid... but you only have access to the community edition...

Screenshot of React Data Grid server side pagination page indicating that it is enterprise-only

The "e" stands for "enterprise only," or possibly "evil."

Breaking down the problem

As was stated, I wanted server side pagination. For my particular use-case, there was also one other requirement. The table needed to behave like a controlled component, i.e. I wanted the row data to be passed in from the parent component and to be able to directly modify that data. Incidentally, the enterprise edition wouldn't have actually helped much in this respect, since server-side row data in react data grid is meant to be loaded through a callback designated in the datasource property. We do however still want the table to kick off loading a new page. So the process of loading in new data should be as follows:

  1. A page that hasn't yet been loaded is navigated to.
  2. The table detects we've navigated to this unloaded page and kicks off an asynchronous callback to load the new row data.
  3. The new row data is returned to the table.
  4. The table splices the new row data into the full row data array.
  5. The table calls an onChange callback so the new row data can be updated in the React state.
  6. The updated row data state is passed back to the table and rendered.

The ControlledTable

We'll create a new React component called ControlledTable to contain the logic for our paginated table. For now though, we'll forget the asynchronous loading and just assume that all our row data is passed to the table at once.

import React, { useEffect, useCallback, useState } from "react"; import { AgGridReact } from "ag-grid-react"; import "ag-grid-community/dist/styles/ag-grid.css"; import "ag-grid-community/dist/styles/ag-theme-material.css"; const ControlledTable = ({ rows, totalCount, pageSize, pageNumber, columnDefs, getRowNodeId, onPageNumberChange, }) => { const [gridApi, setGridApi] = useState(); let paginationProps = { pagination: true, paginationPageSize: pageSize, cacheBlockSize: pageSize }; const onGridReady = ({ api }) => setGridApi(api); const onPaginationChanged = useCallback( ({ newPage }) => { if (!gridApi || !newPage) { return; } const currentPage = gridApi.paginationGetCurrentPage(); onPageNumberChange(currentPage); }, [gridApi, onPageNumberChange] ); useEffect(() => { if (!gridApi || isNaN(pageNumber)) { return; } const currentPage = gridApi.paginationGetCurrentPage(); if (pageNumber === currentPage) { return; } gridApi.paginationGoToPage(pageNumber); }, [gridApi, pageNumber]); return ( <div className="ag-theme-material" style={{ height: "300px" }}> <AgGridReact defaultColDef={{ flex: 1, minWidth: 100 }} columnDefs={columnDefs} rowData={rows} rowCount={totalCount} onPaginationChanged={onPaginationChanged} getRowNodeId={getRowNodeId} onGridReady={onGridReady} {...paginationProps} /> </div> ); }; export default ControlledTable;

For this first version, we've passed the following props:

  • rows: the full row data.
  • totalCount: the number of rows.
  • pageSize: the number of rows per page.
  • pageNumber: the current page number.
  • columnDefs: a definition of the fields in the row data.
  • getRowNodeId: a function for generating a unique id from a row.
  • onPageNumberChange: a callback called when the page number is changed by the user.

We already have a controlled component, because the row data, row count and page number are passed in via properties, and a callback exists to update the page number. We just need a way to kick off an asynchronous loading process for new row data.

Adding Asynchronous Data Loading

We want to define an asynchronous function for grabbing a specified block of rows that we can pass to our ControlledTable as a property. We have to decide on a method signature. It's fairly common to request paginated results using limit and offset parameters to specify how many rows to return and what offset to start at. We want the function to simply resolve with the block of requested rows.

If we want the ControlledTable to update the full row data upon receiving the requested block, we'll need to add an onChange callback for it to call. The onChange callback will in turn tell the parent component to update the row data in the state.

const ControlledTable = ({ rows, totalCount, pageSize, pageNumber, columnDefs, getRowNodeId, onPageNumberChange, + getRows, + onChange, }) => {

For the AgGridReact component to render the page controls properly with incomplete data, we'll need to pad out the rows with placeholder rows. We need the actual length of the array to match totalCount. We'll create a function to create placeholder rows

const getPlaceholderItems = (startRow, length) => { const items = []; for (let index = startRow; index < length; index += 1) { items.push({ index, placeholder: true }); } return items; };

and we'll pad out rows before passing them to the AgGridReact component.

if (rows?.length < totalCount) { rows.splice( rows.length, 0, ...getPlaceholderItems(rows.length, totalCount) ); }

We'll also need to consider how to actually manage the lifecycle of opening a page -> calling getRows -> splicing the rows into the array -> calling the onChange callback.

We want to keep track of which blocks we have to load and which ones we are already in the process of loading. We'll define a state variable, loadingBlocks, which will contain an array of row numbers. The row numbers indicate the first row in the block we want to load.

We'll define a function for checking if a block starting at row startRow needs loading:

const needsLoading = useCallback( (startRow) => { if (!rows?.length) { // We need to load if rows are completely empty return true; } const max = Math.min(startRow + pageSize, rows.length); for (let i = startRow; i < max; i += 1) { if (isPlaceholder(i)) { return true; } } }, [rows, pageSize, isPlaceholder] );

isPlaceholder is a function that simply checks if the row is one of the placeholder rows we padded the rows with:

const isPlaceholder = useCallback((i) => !rows[i] || rows[i].placeholder, [ rows ]);

We'll use needsLoading in a useEffect that runs when the page number changes:

useEffect(() => { const startRow = pageNumber * pageSize; if (!loadingBlocks.includes(startRow) && needsLoading(startRow)) { // We haven't started loading the block yet. Start loading it setLoadingBlocks([...loadingBlocks, startRow]); } }, [loadingBlocks, pageNumber, pageSize, needsLoading]);

We can see that if the current block needs loading, and we haven't yet added it to loadingBlocks, we will add it to loadingBlocks. So now the blocks that need loading have been added to the state.

So now we have a way to track which blocks need loading and we need to use that information to kick off the loading process. Let's start by creating some skeleton useEffects...

for (const startRow of loadingBlocks) { useEffect(() => { console.log("startRow", startRow); }); }

and let's take a look at the console...

Warning: React has detected a change in the order of Hooks called by ControlledTable. This will lead to bugs and errors if not fixed. For more information, read the Rules of Hooks: https://reactjs.org/link/rules-of-hooks Previous render Next render ------------------------------------------------------ 1. useState useState 2. useCallback useCallback 3. useEffect useEffect 4. useCallback useCallback 5. useCallback useCallback 6. useEffect useEffect 7. undefined useEffect

Oof, it didn't like that. React will error out and refuse to render if we change the number or order of hooks. Fortunately, there's a workaround. We'll create an invisible component, i.e. one that doesn't actually render anything, to wrap the useEffects and load our data.

const LoadingBlock = ({ getRows, pageSize, startRow, onLoaded }) => { const [rows, setRows] = useState(); useEffect(() => { let cleaningUp; getRows(pageSize, startRow).then((rows) => { if (cleaningUp) { return; } setRows(rows); }); return () => { cleaningUp = true; }; }, [getRows, pageSize, startRow]); useEffect(() => { if (!rows) { return; } onLoaded(rows, startRow); }, [onLoaded, rows, startRow]); return null; };

It's a pretty simple component. It has one useEffect responsible for calling the getRows callback and updating the local rows state variable. The other useEffect responds to the rows update and calls the onLoaded callback. We want to split these into multiple useEffects because the onLoaded callback may update during the loading process and we don't want to kick off the first useEffect when it happens.

The only part of it that may be a little strange is the cleaningUp variable. If a react state variable is updated after the component is unmounted, an error will be logged to the console. So we'll use cleaningUp to keep track of when we should avoid updating the state. We'll turn it on in the useEffect's cleanup function.

We'll need an onLoaded callback to pass to the LoadingBlock.

const onLoaded = useCallback( (newRows, startRow) => { // We've loaded the block. Update the rows array let rowsCopy = [...rows]; rowsCopy.splice(startRow, pageSize, ...newRows); const newLoadingBlocks = [...loadingBlocks]; newLoadingBlocks.splice(newLoadingBlocks.indexOf(startRow), 1); setLoadingBlocks(newLoadingBlocks); onChange(rowsCopy); }, [rows, pageSize, loadingBlocks, onChange] );

So this callback does two things. It removes the loading block from the loading blocks array to remove the corresponding LoadingBlock component. It also splices the loaded row data into the full row array before passing that updated row data to the onChange callback.

The last bit of wiring up we need to do is to simply render these LoadingBlock components.

{loadingBlocks.map((startRow) => ( <LoadingBlock key={`loadingBlock:${startRow}`} getRows={getRows} startRow={startRow} pageSize={pageSize} onLoaded={onLoaded} /> ))}

Loading Overlay

One more optional piece of functionality we should add is preventing the user from interacting with rows as they're loading. Let's add one more useEffect.

useEffect(() => { if (!gridApi) { return; } if (loadingBlocks.includes(pageNumber * pageSize)) { gridApi.showLoadingOverlay(); } else { gridApi.hideOverlay(); } }, [gridApi, pageNumber, pageSize, loadingBlocks]);

When the current page is loading, we'll show the loading overlay. Otherwise, we'll hide it.

Have fun!

We're done. Play around with the completed version in CodeSandbox.