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 reuse
  • onFirstDataRendered: Restore saved state
  • onFilterChanged: Persist filters
  • onSortChanged: Persist sort order
  • onPageChanged: Persist pagination
  • onSelectionChanged: Persist row selection
  • onRowDataUpdated: Reapply selections after data reload
  • useEffect: 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 page value 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.