How to Build a Reusable React Grid Component
Get a summary of this article:
In most enterprise React apps, the grid shows up on dozens of screens: orders, customers, invoices, audit logs. Without a shared component, each team configures it from scratch. Currency formats differ, one screen validates edits and another doesn’t, and an upgrade means touching every file that renders a grid.
A reusable React grid component fixes that. You decide once how columns are typed, formatted and edited, how data loads, and how edits are saved. Every screen then describes what it shows, in a few lines, and the shared component handles how.
This guide builds that component step by step in TypeScript, on top of the Ext JS grid running in React through ReExt. You’ll end up with a <DataGrid> your team can drop into any screen, with typed columns, inline editing with validation, grouping and summaries, and local or remote data.

Why a reusable grid component pays off
| Without a shared grid | With a shared grid |
|---|---|
| Each screen writes 60–100 lines of grid configuration | Each screen passes a short column list |
| Formats, validation and empty states vary by team | One set of defaults, applied everywhere |
| Save and error handling is reimplemented per screen | One tested path for saving and rolling back edits |
| Upgrades touch every grid in the app | Upgrades happen in one component |
| New developers learn the grid library’s full API | New developers learn a small, documented set of props |
The Ext JS grid is a strong base for this, because the hard features (editing, grouping, summaries, paging, remote sorting) are already built in. Your component’s job is to choose sensible defaults and expose a small, team-friendly API on top.
Design the component API first
The most important decision is what not to expose. If your <DataGrid> simply passes through every Ext JS option, it is a thin wrapper, not a shared component. Instead, give screens a small vocabulary that covers 90% of needs, and decide everything else once, inside the component.
Here is the API this guide builds:
| Prop | Purpose |
|---|---|
| columns | A simple column list: field, header, type, size, editable, required, options, summary |
| data or url | Local rows, or an API endpoint for remote paging, sorting and filtering |
| groupBy | Field to group rows by |
| title, height, emptyText | Presentation |
| onSelectionChange(rows) | Selected rows, as plain objects |
| onCellEdit(change) | Called on each edit; resolve to keep it, throw to roll it back |
| ref | Methods: reload(), clearFilters(), getSelection() |
And the column types every screen picks from:
| Type | Display | Editor |
|---|---|---|
| text (default) | Plain text | Text field, or dropdown when options are given |
| number | 1,234 | Number field |
| currency | $1,234.00 | Number field |
| date | Sep 14, 2026 | Date picker |
| boolean | Yes / No, or a checkbox when editable | Checkbox |
Three principles guide the design:
- Screens describe data, not widgets. A screen says “this column is currency and required,” never “use a numbercolumn with this format string.”
- Plain objects in, plain objects out. Callbacks receive ordinary row objects, so screen code never touches Ext JS records.
- An escape hatch, used rarely. The ref exposes a few methods for the cases the props don’t cover.
Step 1: Map simple columns to Ext JS columns
The heart of the component is a pure function that turns your team’s column definition into an Ext JS column config. Because it’s pure, it’s easy to test and the only place formats and editors are decided.
Create src/components/grid/gridColumns.ts:
declare const Ext: any; // provided globally once ReExtProvider loads Ext JS
export type ColumnType = 'text' | 'number' | 'currency' | 'date' | 'boolean';
export type SummaryType = 'sum' | 'count' | 'min' | 'max' | 'average';
export interface GridColumn {
field: string;
header: string;
type?: ColumnType;
width?: number;
flex?: number;
editable?: boolean;
required?: boolean;
options?: string[]; // turns the editor into a dropdown
summary?: SummaryType;
hidden?: boolean;
}
const FORMATS = {
number: '0,000',
currency: '$0,000.00',
date: 'M j, Y',
} as const;
const isNumeric = (t: ColumnType): t is 'number' | 'currency' =>
t === 'number' || t === 'currency';
function toEditor(col: GridColumn, type: ColumnType) {
const allowBlank = !col.required;
if (col.options) {
return { xtype: 'combobox', store: col.options, forceSelection: true, allowBlank };
}
if (isNumeric(type)) return { xtype: 'numberfield', allowBlank };
if (type === 'date') return { xtype: 'datefield', format: FORMATS.date, allowBlank };
return { xtype: 'textfield', allowBlank };
}
export function toExtColumn(col: GridColumn): Record<string, unknown> {
const type = col.type ?? 'text';
const ext: Record<string, unknown> = {
text: col.header,
dataIndex: col.field,
hidden: col.hidden ?? false,
...(col.flex ? { flex: col.flex } : { width: col.width ?? 140 }),
};
if (isNumeric(type)) {
const format = FORMATS[type];
Object.assign(ext, { xtype: 'numbercolumn', format, align: 'right' });
if (col.summary && col.summary !== 'count') {
ext.summaryRenderer = (v: number) => Ext.util.Format.number(v, format);
}
} else if (type === 'date') {
Object.assign(ext, { xtype: 'datecolumn', format: FORMATS.date });
} else if (type === 'boolean') {
Object.assign(ext, col.editable
? { xtype: 'checkcolumn' }
: { xtype: 'booleancolumn', trueText: 'Yes', falseText: 'No' });
}
if (col.editable && type !== 'boolean') ext.editor = toEditor(col, type);
if (col.summary) ext.summaryType = col.summary;
return ext;
}
// Store field types, so API strings become real dates and numbers.
export function toFields(columns: GridColumn[]) {
return columns.map(({ field, type = 'text' }) => {
if (type === 'date') return { name: field, type: 'date', dateFormat: 'Y-m-d' };
if (isNumeric(type)) return { name: field, type: 'float' };
if (type === 'boolean') return { name: field, type: 'boolean' };
return { name: field, type: 'string' };
});
}
Every formatting decision now lives in FORMATS. To change how currency displays across the whole app, you edit one line. The dateFormat in toFields assumes your API returns ISO dates (2026-09-14); adjust it once if yours differs.
Step 2: Build the DataGrid component
Now wrap the Ext JS grid. This assumes ReExt is installed and your app is wrapped in ReExtProvider, as covered in our Ext JS grid in React tutorial.
Create src/components/grid/DataGrid.tsx:
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import ReExt from '@sencha/reext';
import { GridColumn, toExtColumn, toFields } from './gridColumns';
export type Row = Record<string, unknown>;
export interface CellChange {
row: Row;
field: string;
value: unknown;
oldValue: unknown;
}
export interface DataGridProps {
columns: GridColumn[];
data?: Row[];
url?: string;
pageSize?: number;
groupBy?: string;
title?: string;
height?: number | string;
emptyText?: string;
onSelectionChange?: (rows: Row[]) => void;
onCellEdit?: (change: CellChange) => Promise<void> | void;
}
export interface DataGridHandle {
reload: () => void;
clearFilters: () => void;
getSelection: () => Row[];
}
const DataGrid = forwardRef<DataGridHandle, DataGridProps>(function DataGrid(
{
columns, data, url, pageSize = 100, groupBy, title,
height = 420, emptyText = 'No records to show',
onSelectionChange, onCellEdit,
},
ref,
) {
const gridRef = useRef<any>(null);
// Always call the latest callbacks without rebuilding the grid.
const callbacks = useRef({ onSelectionChange, onCellEdit });
callbacks.current = { onSelectionChange, onCellEdit };
// Build the Ext JS config once per column set or data source.
const config = useMemo(() => {
const editable = columns.some((c) => c.editable);
const hasSummary = columns.some((c) => c.summary);
const store: Record<string, unknown> = { fields: toFields(columns) };
if (groupBy) store.groupField = groupBy;
if (url) {
Object.assign(store, {
autoLoad: true, pageSize, remoteSort: true, remoteFilter: true,
proxy: {
type: 'ajax', url,
reader: { type: 'json', rootProperty: 'data', totalProperty: 'total' },
},
});
} else {
store.data = data ?? [];
}
const features: Record<string, unknown>[] = [];
if (groupBy) features.push({ ftype: hasSummary ? 'groupingsummary' : 'grouping' });
if (hasSummary) features.push({ ftype: 'summary', dock: 'bottom' });
return {
title,
emptyText,
columnLines: true,
store,
columns: columns.map(toExtColumn),
features,
plugins: editable ? [{ ptype: 'cellediting', clicksToEdit: 1 }] : [],
...(url ? { bbar: { xtype: 'pagingtoolbar', displayInfo: true } } : {}),
};
// `data` is applied separately below, so new rows don't rebuild the grid.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columns, url, pageSize, groupBy, title, emptyText]);
// Push new local data into the existing store.
useEffect(() => {
if (!url && data && gridRef.current) gridRef.current.getStore().loadData(data);
}, [data, url]);
useImperativeHandle(ref, () => ({
reload: () => { if (url) gridRef.current?.getStore().reload(); },
clearFilters: () => gridRef.current?.getStore().clearFilter(),
getSelection: () => (gridRef.current?.getSelection() ?? []).map((r: any) => r.getData()),
}), [url]);
const handleSelection = useCallback((_sm: unknown, records: any[]) => {
callbacks.current.onSelectionChange?.(records.map((r) => r.getData()));
}, []);
const handleEdit = useCallback(async (_editor: unknown, ctx: any) => {
if (ctx.value === ctx.originalValue) return;
const save = callbacks.current.onCellEdit;
if (!save) { ctx.record.commit(); return; }
try {
await save({ row: ctx.record.getData(), field: ctx.field, value: ctx.value, oldValue: ctx.originalValue });
ctx.record.commit(); // keep the change
} catch {
ctx.record.reject(); // roll back to the original value
}
}, []);
return (
<ReExt
xtype="grid"
style={{ height }}
config={config}
ready={(cmp: any) => { gridRef.current = cmp; }}
onSelectionchange={handleSelection}
onEdit={handleEdit}
/>
);
});
export default DataGrid;
Four details make this component safe to share:
- Stable config. The Ext JS config is memoized on the inputs that change the grid’s structure. New data is loaded into the existing store instead, so refreshing rows doesn’t rebuild the grid or lose the user’s scroll position, sorting and column widths.
- Latest-callback ref. Event handlers are created once and read the newest onSelectionChange and onCellEdit from a ref, so parent re-renders never leave the grid calling stale functions.
- Save-or-roll-back editing. If onCellEdit resolves, the edit is committed; if it throws, the cell reverts to its original value. Every screen gets the same, correct behavior.
- Plain objects only. Callbacks and getSelection() return ordinary row objects, so screen code never depends on Ext JS internals.
Step 3: Use it on real screens
With the component in place, a full-featured screen is mostly a column list.
An editable, grouped orders screen with remote data
import { useRef } from 'react';
import DataGrid, { DataGridHandle } from '../components/grid/DataGrid';
import type { GridColumn } from '../components/grid/gridColumns';
// Defined outside the component, so the grid config stays stable.
const orderColumns: GridColumn[] = [
{ field: 'customer', header: 'Customer', flex: 1, editable: true, required: true, summary: 'count' },
{ field: 'region', header: 'Region', width: 110 },
{ field: 'amount', header: 'Amount', type: 'currency', editable: true, required: true, summary: 'sum' },
{ field: 'orderDate', header: 'Order date', type: 'date', editable: true },
{ field: 'status', header: 'Status', editable: true, options: ['Open', 'Shipped', 'Closed'] },
{ field: 'priority', header: 'Priority', type: 'boolean' },
];
async function saveOrder(id: unknown, patch: Record<string, unknown>) {
const res = await fetch(`/api/orders/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(`Save failed: ${res.status}`);
}
export function OrdersPage() {
const grid = useRef<DataGridHandle>(null);
return (
<>
<button onClick={() => grid.current?.reload()}>Refresh</button>
<DataGrid
ref={grid}
title="Orders"
url="/api/orders"
groupBy="region"
columns={orderColumns}
onCellEdit={({ row, field, value }) => saveOrder(row.id, { [field]: value })}
onSelectionChange={(rows) => console.log('Selected', rows)}
/>
</>
);
}
That screen gets typed columns, currency and date formatting, validation on required fields, a status dropdown, grouping by region with subtotals and a grand total, remote paging and sorting, and save-or-roll-back editing, in about 40 lines with no grid configuration.
A read-only customers screen with local data
import DataGrid, { Row } from '../components/grid/DataGrid';
import type { GridColumn } from '../components/grid/gridColumns';
const customerColumns: GridColumn[] = [
{ field: 'name', header: 'Customer', flex: 1 },
{ field: 'country', header: 'Country', width: 140 },
{ field: 'since', header: 'Customer since', type: 'date' },
{ field: 'lifetime', header: 'Lifetime value', type: 'currency' },
{ field: 'active', header: 'Active', type: 'boolean' },
];
export function CustomersPage({ customers }: { customers: Row[] }) {
return <DataGrid title="Customers" data={customers} columns={customerColumns} />;
}
Both screens look and behave the same way, because every formatting and editing decision comes from the shared component.
One note on remote grouping: with url and groupBy, the grid groups the rows on each loaded page, and the store sends sort and group parameters to your API. Make sure the API sorts by the group field so groups don’t split across pages.
Alternative for Ext JS teams: a shared base grid class
If your team already has an Ext JS application, you may already have standard grid classes defined with Ext.define, with your defaults, plugins and overrides. ReExt can load those custom components directly, so you can reuse them in React instead of rebuilding them.
Define (or reuse) a base class, for example src/components/grid/CompanyGrid.js:
Ext.define('Company.grid.Base', {
extend: 'Ext.grid.Panel',
xtype: 'companygrid',
columnLines: true,
emptyText: 'No records to show',
plugins: [{ ptype: 'cellediting', clicksToEdit: 1 }],
});
Then load it once Ext JS is available and render it by its xtype:
import { useEffect, useState } from 'react';
import ReExt from '@sencha/reext';
export function OrdersGrid({ columns, store }) {
const [ready, setReady] = useState(false);
useEffect(() => {
import('./CompanyGrid').then(() => setReady(true));
}, []);
if (!ready) return <div>Loading…</div>;
return <ReExt xtype="companygrid" style={{ height: 420 }} config={{ store, columns }} />;
}
Which approach to choose:
| Situation | Best approach |
|---|---|
| New React app, or a team new to Ext JS | The TypeScript <DataGrid> from Steps 1–3 |
| Existing Ext JS app with standard grid classes | Reuse the classes with xtype, as above |
| Both | Point <DataGrid> at your custom xtype instead of grid, combining your Ext JS defaults with the simple React API |
The third option is often the best path during a migration: React screens get a clean, typed API, while the grid keeps behaving exactly like the one users already know.
Testing, documenting and governing the component
A shared component only pays off if teams trust it. Three practices help.
Unit-test the column mapper
Because toExtColumn is a pure function, you can test every formatting and editing rule without rendering anything. With Vitest:
import { describe, it, expect } from 'vitest';
import { toExtColumn } from './gridColumns';
describe('toExtColumn', () => {
it('formats currency columns', () => {
expect(toExtColumn({ field: 'amount', header: 'Amount', type: 'currency' }))
.toMatchObject({ xtype: 'numbercolumn', format: '$0,000.00', align: 'right' });
});
it('adds a dropdown editor when options are given', () => {
const col = toExtColumn({ field: 'status', header: 'Status', editable: true, options: ['Open', 'Closed'] });
expect(col.editor).toMatchObject({ xtype: 'combobox', store: ['Open', 'Closed'] });
});
it('marks required fields', () => {
const col = toExtColumn({ field: 'name', header: 'Name', editable: true, required: true });
expect(col.editor).toMatchObject({ allowBlank: false });
});
});
Test behavior end to end
Use Playwright or Cypress for what only a browser can check: a cell edit calls the API, a failed save rolls the value back, and grouping shows the right subtotals. A handful of these tests on a demo page protects every screen that uses the component.
Document it like a product
Publish the props, column types and examples in your internal docs or Storybook (with a decorator that wraps stories in ReExtProvider). New developers should be able to build a grid screen from the docs alone.
Best practices for a reusable React grid component
- Keep the API small. Add a prop only when several screens need it. One-off needs go through the ref escape hatch or a custom column type.
- Define columns outside components. Inline column arrays are recreated every render and would rebuild the grid.
- Put formats in one place. Currency, date and number formats belong in the mapper, not in screens.
- Return plain objects. Keep Ext JS records inside the component so screen code stays simple and portable.
- Make saving explicit. Resolve to keep an edit, throw to roll it back; never leave users unsure whether a change was saved.
- Add an xtype prop when you need it. Defaulting to grid but allowing a custom xtype lets you reuse existing Ext JS grid classes behind the same API.
- Version it. Treat the component as an internal package with a changelog, so teams know when behavior changes.
Frequently asked questions
What is a reusable React grid component?
It’s a single, shared React data grid component that every screen in an app uses, with a small set of props for columns, data and callbacks. Formatting, editing, validation and data loading are decided once inside it, so grids look and behave the same everywhere.
Why build it on the Ext JS grid?
The Ext JS grid already includes the hard features: typed columns, cell editing with validation, grouping, summaries, paging and remote sorting. Your component only has to choose defaults and expose a simple API, rather than build those features. ReExt makes the Ext JS grid available inside React.
Can I write the component in TypeScript?
Yes. The ReExt package ships with TypeScript declarations, and the component in this guide is written in TypeScript, with typed column definitions and props.
How do I stop the grid from re-rendering when data changes?
Keep the grid config memoized on structural inputs such as columns and data source, and load new rows into the existing store with loadData. The grid then keeps its scroll position, sorting and column widths.
Can I reuse grid classes from my existing Ext JS app?
Yes. ReExt can load custom Ext JS components defined with Ext.define and render them by xtype, so existing grid classes, defaults and plugins can be reused in React.
Build the grid once, use it everywhere
A reusable React grid layout turns your grid from something every team configures into something every team simply uses. Screens shrink to a column list, behavior becomes consistent, and upgrades happen in one place.
Building it on the Ext JS grid through ReExt means the advanced features are already there. Your shared component just decides the defaults, and for teams with an existing Ext JS app, it can reuse the grid classes you already trust.
Search for “React grid component,” and you will find two very different things. Half the…
Set up the Ext JS grid in React with ReExt, then add typed columns, store-backed…
Almost every business application eventually becomes a grid. Orders, invoices, trades, tickets, inventory, patients, shipments:…




