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...
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:
- A page that hasn't yet been loaded is navigated to.
- The table detects we've navigated to this unloaded page and kicks off an asynchronous callback to load the new row data.
- The new row data is returned to the table.
- The table splices the new row data into the full row data array.
- The table calls an
onChange
callback so the new row data can be updated in the React state. - 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 useEffect
s 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.