When creating enterprise-grade data tables using React, user experience and state management are critical. Making sure the grid retains filters, sorting, pagination, and chosen rows is a frequent problem, particularly when navigating between pages or components.
We’ll demonstrate how to use the Context API to create a reliable, permanent AG Grid table in this blog. Your AG Grid tables will feel clean and user-friendly with this configuration, which will assist them maintain their state across renderings and navigation.
- ✅ Persistent filters, sort, pagination, selected rows
- ✅ Context API for shared state
- ✅ Seamless integration for complex apps and modals
Why Context API?
AG Grid is powerful—but managing its complex state (especially across multiple pages or components) can get messy. The Context API lets us maintain a global reference to AG Grid’s APIs, while tracking table-specific state (like selected filters) in a centralized reducer.
📁 agGridContext.tsx
// Context and reducer for AG Grid
import React, { createContext, useCallback, useContext, useReducer, useRef, useState } from "react";
import { ColumnState, GridApi, RowNode } from "ag-grid-community";
// Define State Structure
interface PageState {
selectedRows: RowNode[];
selectedFilters: unknown;
selectedSort: ColumnState[];
selectedPageIndex: number;
}
interface AgGridState {
pages: Record
pageSize: number;
}
interface AgGridAction {
type: string;
payload?: {
page?: string;
data?: unknown;
};
}
interface AgGridContextType {
gridApiRef: React.MutableRefObject<{ [key: string]: { api: GridApi; } | null }>;
state: AgGridState;
dispatch: React.Dispatch;
setGridApiRef: (page: string, params: { api: GridApi; }) => void;
resetGridApiRef: (page: string) => void;
gridReady: boolean,
setGridReady: (page: boolean) => void;
}
// Reducer Function
const agGridReducer = (state: AgGridState, action: AgGridAction): AgGridState => {
switch (action.type) {
case "SET_PAGE_STATE": {
const page = action.payload!.page!;
const data = action.payload!.data!;
return {
...state,
pages: {
...state.pages,
[page]: {
...(state.pages[page] || {}),
...data,
},
},
};
}
case "RESET_PAGE_STATE": {
const page = action.payload!.page!;
const updatedPages = { ...state.pages };
delete updatedPages[page];
return {
...state,
pages: updatedPages,
};
}
default:
return state;
}
};
// Create Context
const AgGridContext = createContext(undefined);
export const AgGridProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [state, dispatch] = useReducer(agGridReducer, { pageSize: 20, pages: {} });
const [gridReady, setGridReady] = useState(false)
const gridApiRef = useRef<{ [key: string]: { api: GridApi } | null }>({});
const setGridApiRef = (page: string, params: { api: GridApi }) => {
gridApiRef.current[page] = params;
setGridReady(true)
};
const resetGridApiRef = useCallback((page: string) => {
if (gridApiRef.current[page]) {
gridApiRef.current[page] = null
setGridReady(false)
}
}, []);
return (
<AgGridContext.Provider value={{ gridApiRef, state, dispatch, setGridApiRef, resetGridApiRef, gridReady, setGridReady }}>
{children}
</AgGridContext.Provider>
);
};
// Hook for Consuming Context
export const useAgGridData = (): AgGridContextType => {
const context = useContext(AgGridContext);
if (!context) {
throw new Error("useAgGridData must be used within an AgGridProvider");
}
return context;
};
export const useAgGridData = () => useContext(AgGridContext);
📊 Table.tsx
// Reusable AG Grid Table Component
import React, { useCallback, useEffect, useMemo } from "react";
import { AgGridReact } from "ag-grid-react";
import {
AllCommunityModule,
ColDef,
FilterChangedEvent,
FirstDataRenderedEvent,
GridReadyEvent,
ModuleRegistry,
PaginationChangedEvent,
RowDataUpdatedEvent,
SortChangedEvent,
themeBalham
} from "ag-grid-community";
import { useAgGridData } from "@/context";
import 'ag-grid-community/styles/ag-theme-alpine.css';
import 'ag-grid-community/styles/ag-theme-balham.css';
ModuleRegistry.registerModules([AllCommunityModule]);
interface TableProps {
rowData: unknown[] | undefined;
columnDefs: ColDef[];
page: string;
frameworkComponents?: unknown;
rowSelection?: "singleRow" | "multiRow";
rowSelectionMatchKey?: string;
suppressRowClickSelection?: boolean;
getRowStyle?: (params: unknown) => object;
isModalTable?: boolean;
loading?: boolean;
}
const Table: React.FC = ({
columnDefs,
frameworkComponents,
page,
rowData,
rowSelection,
rowSelectionMatchKey = "id",
isModalTable = false,
loading = false,
}) => {
const { gridApiRef, setGridApiRef, resetGridApiRef, state, dispatch } = useAgGridData() || {};
const { pageSize } = state || {};
const { selectedFilters, selectedSort, selectedPageIndex, selectedRows } = state?.pages[page] || {};
const defaultColDef: ColDef = useMemo(
() => ({
flex: 1,
filter: "agTextColumnFilter",
menuTabs: ["filterMenuTab"],
}),
[]
);
// Store and restore filters/sorting/pagination on the mount.
const onFirstDataRendered = (params: FirstDataRenderedEvent) => {
if (selectedFilters) {
params.api.setFilterModel(selectedFilters);
}
setTimeout(() => {
if (pageSize) {
params.api.setGridOption("paginationPageSize", pageSize);
}
if (selectedPageIndex) {
params.api.paginationGoToPage(selectedPageIndex);
}
if (selectedSort) {
params.api.applyColumnState({ state: selectedSort });
}
}, 0);
};
// Handle Row Selection Changes
const onSelectionChanged = useCallback(() => {
const selected = gridApiRef?.current?.[page]?.api?.getSelectedRows() || [];
dispatch({ type: "SET_PAGE_STATE", payload: { page, data: { selectedRows: selected } } });
}, [gridApiRef, page, dispatch]);
// Capture Grid API
const onGridReady = (params: GridReadyEvent) => {
if (gridApiRef?.current && setGridApiRef) {
setGridApiRef(page, params);
}
};
// Capture Pagination Changes
const onPageChanged = (params: PaginationChangedEvent) => {
if (params.newPage) {
const currentPage = gridApiRef?.current?.[page]?.api?.paginationGetCurrentPage() || 0;
dispatch({ type: "SET_PAGE_STATE", payload: { page, data: { selectedPageIndex: currentPage } } });
}
};
const onFilterChanged = (params: FilterChangedEvent) => {
dispatch({ type: "SET_PAGE_STATE", payload: { page, data: { selectedFilters: params.api.getFilterModel() } } })
};
// Capture Sorting Changes
const onSortChanged = (params: SortChangedEvent) => {
dispatch({ type: "SET_PAGE_STATE", payload: { page, data: { selectedSort: params.api.getColumnState() } } });
};
// Restore Row Selection
const onRowDataUpdated = (params: RowDataUpdatedEvent) => {
if (selectedRows?.length) {
params.api.forEachNode((node) => {
const rData = node.data as Record;
const match = selectedRows.some((row) => {
const selectedRow = row as Record;
return selectedRow[rowSelectionMatchKey] === rData[rowSelectionMatchKey];
});
if (match) {
node.setSelected(true, false);
}
});
}
};
const clearAllSelected = () => {
gridApiRef?.current?.[page]?.api?.deselectAll();
dispatch({ type: "SET_PAGE_STATE", payload: { page, data: { selectedRows: [] } } });
};
const modifiedColDef: ColDef[] = useMemo(() => {
return columnDefs.map((col: ColDef) => {
return ['Action', '#'].indexOf(col?.headerName || '') === -1 ? {
resizable: true,
sortable: true,
suppressMenu: true,
floatingFilter: true,
tooltipField: col.field,
...col
} : col
})
}, [columnDefs]);
useEffect(() => {
return () => {
if (resetGridApiRef && !isModalTable) {
resetGridApiRef(page);
}
};
}, [page, resetGridApiRef, isModalTable]);
return (
<div style={{ width: "100%" }}>
<AgGridReact
domLayout="autoHeight"
rowModelType="clientSide"
theme={themeBalham}
rowData={rowData}
onRowDataUpdated={onRowDataUpdated}
onFirstDataRendered={onFirstDataRendered}
onGridReady={onGridReady}
onFilterChanged={onFilterChanged}
onSortChanged={onSortChanged}
onSelectionChanged={onSelectionChanged}
frameworkComponents={frameworkComponents}
pagination
paginationPageSize={pageSize}
onPaginationChanged={onPageChanged}
defaultColDef={defaultColDef}
columnDefs={modifiedColDef}
loading={loading}
{...(rowSelection && { rowSelection: { "mode": rowSelection, selectAll: 'filtered' } })}
/>
{selectedRows?.length > 0 && (
<div>
Selected {selectedRows.length} record(s).
<button onClick={clearAllSelected} className="underline text-blue-500">Clear All</button>
</div>
)}
</div>
);
};
export default Table;
🚀 How to Use
<AgGridProvider>
<Table
page="userList"
rowData={users}
columnDefs={userColumns}
rowSelection="multiRow"
/>
</AgGridProvider>
✅ Summary of Function Purposes
onGridReady: Store API globally for reuseonFirstDataRendered: Restore saved stateonFilterChanged: Persist filtersonSortChanged: Persist sort orderonPageChanged: Persist paginationonSelectionChanged: Persist row selectiononRowDataUpdated: Reapply selections after data reloaduseEffect: Cleanup when component unmounts
🏷️ Why page="userList" is Required
The page prop ensures that each instance of the table maintains its own unique state. Here’s why it’s essential:
- ✅ Unique State Keys: Each table uses the
pagevalue to store and retrieve filters, pagination, and sort order. - ✅ Supports Multiple Tables: Prevents one table’s state from affecting another’s.
- ✅ Enables State Restoration: Helps reapply filters, sort, and selected rows when returning to a table.
- ✅ Safe Cleanup: Lets you clean up only the relevant table state when a component unmounts.
🔎 Example: What does state look like?
{
pages: {
userList: {
selectedFilters: { name: { type: "contains", filter: "John" } },
selectedSort: [ { colId: "email", sort: "asc" } ],
selectedPageIndex: 1,
selectedRows: [...]
},
productList: {
selectedFilters: { category: { type: "equals", filter: "Books" } },
...
}
}
}
🔁 This setup ensures AG Grid state survives across page changes, modals, or tab switches—great for UIs.
Leave a Reply