Building a Reusable React UI Component Around the Ext JS Data Grid
Get a summary of this article:
The Ext JS data grid can do almost anything an enterprise screen needs: grouping, summaries, filtering, cell and row editing, locked columns, spreadsheet-style selection and export. That breadth is exactly why it deserves a wrapper. If every team configures it from scratch, they’ll each turn on a different subset of features, in a different way, and your app ends up with ten grids that behave like ten different products.
This guide shows how to wrap the Ext JS data grid, through ReExt, in a reusable React UI component that teams switch features on with simple flags, extend with toolbar actions, and listen to with clean, typed events. It builds on our earlier guide to building a reusable React grid component, which covers typed columns and save-or-roll-back editing, and takes the component the rest of the way to a shared, documented, tested part of your design system.

What makes a grid component truly reusable
A wrapper that passes through every Ext JS option isn’t reusable; it’s just indirection. A reusable grid component has five properties:
- Features as flags, not configuration. Screens say filters: true or groupBy: ‘region’; the component knows which plugins and settings that means.
- Slots for screen-specific behavior. Toolbar actions, such as Approve or Assign, are passed in as data, so screens add behavior without touching grid internals.
- Plain, typed events. Selections, edits and view changes come out as ordinary objects, never Ext JS records.
- A small escape hatch. A ref exposes a few methods for the rare cases the props don’t cover.
- Documentation and tests. Every feature flag has a story and a test, so teams trust the component instead of working around it.
Design the component API
The component in this guide is called EnterpriseGrid. It reuses the typed column definitions (GridColumn, toExtColumn and toFields) from our reusable grid component guide, and adds three layers on top: feature flags, toolbar actions and view-state events.
Props
| Prop | Type | Purpose |
|---|---|---|
| columns | GridColumn[] | Typed column definitions |
| data | Row[] | The rows to show |
| features | GridFeatures | Which grid features to switch on |
| actions | GridAction[] | Buttons for the grid’s toolbar |
| title, height, exportFileName | strings and numbers | Presentation and export file name |
| onSelectionChange | (rows) => void | Selected rows as plain objects |
| onCellEdit | (change) => Promise<void> | Resolve to keep an edit, throw to roll it back |
| onViewChange | (view) => void | Current sorters, filters and row count |
| ref | EnterpriseGridHandle | getSelection(), exportTo() and setData() |
Feature flags
| Flag | What it switches on |
|---|---|
| groupBy: ‘field’ | Row grouping by that field |
| summaries: true | Group subtotals (when grouped) and a grand-total row |
| filters: true | Filter menus on every column, matched to its type |
| editing: ‘cell’ | ‘row’ | Cell or row editing for editable columns |
| lockColumns: n | Locks the first n columns |
| spreadsheetSelection: true | Excel-style cell range selection and clipboard |
| exportable: true | An Export menu with Excel and CSV (Enterprise plan) |
Screens never see a plugin name. If the underlying configuration needs to change, for example to adopt a new Ext JS option, the change happens once, inside the component.
Step 1: Turn feature flags into grid configuration
Start with a pure function that converts the column list and feature flags into the Ext JS plugins, features, selection model and column settings. Because it’s pure, it’s easy to unit-test, and it’s the only place that knows how each flag maps to Ext JS.
Create src/components/grid/gridFeatures.ts:
import { GridColumn, toExtColumn } from './gridColumns';
export interface GridFeatures {
groupBy?: string;
summaries?: boolean;
filters?: boolean;
editing?: 'cell' | 'row';
lockColumns?: number;
spreadsheetSelection?: boolean;
exportable?: boolean; // needs the Enterprise exporter package
}
type Cfg = Record<string, unknown>;
// Pick the right filter menu for each column type.
function filterFor(col: GridColumn): unknown {
if (col.options) return { type: 'list', options: col.options };
switch (col.type) {
case 'number':
case 'currency': return 'number';
case 'date': return 'date';
case 'boolean': return 'boolean';
default: return 'string';
}
}
export function buildGridConfig(columns: GridColumn[], f: GridFeatures = {}) {
const plugins: Cfg[] = [];
const features: Cfg[] = [];
if (f.filters) plugins.push({ ptype: 'gridfilters' });
if (f.editing === 'cell') plugins.push({ ptype: 'cellediting', clicksToEdit: 1 });
if (f.editing === 'row') plugins.push({ ptype: 'rowediting', clicksToEdit: 2 });
if (f.spreadsheetSelection) plugins.push({ ptype: 'clipboard' });
if (f.exportable) plugins.push({ ptype: 'gridexporter' });
if (f.groupBy) features.push({ ftype: f.summaries ? 'groupingsummary' : 'grouping' });
if (f.summaries) features.push({ ftype: 'summary', dock: 'bottom' });
const lock = f.lockColumns ?? 0;
return {
plugins,
features,
...(f.spreadsheetSelection ? { selModel: { type: 'spreadsheet', cellSelect: true } } : {}),
columns: columns.map((col, i) => {
const ext: Cfg = toExtColumn(col);
if (f.filters) ext.filter = filterFor(col);
if (i < lock) ext.locked = true;
return ext;
}),
};
}
Editing only applies to columns marked editable in their GridColumn definition, because that’s where toExtColumn adds an editor. A screen can therefore switch on editing for the whole grid and still keep some columns read-only.
Step 2: Build the EnterpriseGrid component
Now the component itself. It combines the feature builder with a toolbar built from actions, an optional Export menu, and three event channels. Create src/components/grid/EnterpriseGrid.tsx:
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
import ReExt from '@sencha/reext';
import { GridColumn, toFields } from './gridColumns';
import { buildGridConfig, GridFeatures } from './gridFeatures';
export type Row = Record<string, unknown>;
export interface GridAction {
id: string;
text: string;
requiresSelection?: boolean; // disabled until a row is selected
onClick: (selected: Row[]) => void;
}
export interface ViewState {
sorters: { property: string; direction: string }[];
filters: { property: string; value: unknown; operator?: string }[];
count: number;
}
export interface CellChange { row: Row; field: string; value: unknown; oldValue: unknown }
export interface EnterpriseGridProps {
columns: GridColumn[];
data: Row[];
features?: GridFeatures;
actions?: GridAction[];
title?: string;
height?: number | string;
exportFileName?: string;
onSelectionChange?: (rows: Row[]) => void;
onCellEdit?: (change: CellChange) => Promise<void> | void;
onViewChange?: (view: ViewState) => void;
}
export interface EnterpriseGridHandle {
getSelection: () => Row[];
exportTo: (type: 'xlsx' | 'csv') => void;
setData: (rows: Row[]) => void;
}
const EnterpriseGrid = forwardRef<EnterpriseGridHandle, EnterpriseGridProps>(function EnterpriseGrid(props, ref) {
const { columns, data, features = {}, actions = [], title, height = 460, exportFileName = 'export' } = props;
const gridRef = useRef<any>(null);
// Always call the latest callbacks without rebuilding the grid.
const latest = useRef(props);
latest.current = props;
const selectedRows = (): Row[] =>
(gridRef.current?.getSelection() ?? []).map((r: any) => r.getData());
const exportTo = (type: 'xlsx' | 'csv') =>
gridRef.current?.saveDocumentAs(type === 'xlsx'
? { type: 'excel07', title, fileName: `${exportFileName}.xlsx` }
: { type: 'csv', fileName: `${exportFileName}.csv` });
const config = useMemo(() => {
const tbar: unknown[] = actions.map((a) => ({
text: a.text,
itemId: a.id,
requiresSelection: !!a.requiresSelection,
disabled: !!a.requiresSelection,
handler: () => latest.current.actions?.find((x) => x.id === a.id)?.onClick(selectedRows()),
}));
if (features.exportable) {
tbar.push('->', {
text: 'Export',
menu: [
{ text: 'Excel (.xlsx)', handler: () => exportTo('xlsx') },
{ text: 'CSV', handler: () => exportTo('csv') },
],
});
}
return {
title,
columnLines: true,
...(tbar.length ? { tbar } : {}),
store: {
fields: toFields(columns),
data,
...(features.groupBy ? { groupField: features.groupBy } : {}),
listeners: {
// Report sorting, filtering and row count after every change.
datachanged: (store: any) => latest.current.onViewChange?.({
sorters: store.getSorters().getRange().map((s: any) =>
({ property: s.getProperty(), direction: s.getDirection() })),
filters: store.getFilters().getRange().map((f: any) =>
({ property: f.getProperty(), value: f.getValue(), operator: f.getOperator() })),
count: store.getCount(),
}),
},
},
...buildGridConfig(columns, features),
listeners: {
selectionchange: (selModel: unknown, records: any[]) => {
gridRef.current?.query('button[requiresSelection=true]')
.forEach((b: any) => b.setDisabled(!records.length));
latest.current.onSelectionChange?.(records.map((r) => r.getData()));
},
edit: async (editor: unknown, ctx: any) => {
if (ctx.value === ctx.originalValue) return;
const save = latest.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();
} catch {
ctx.record.reject();
}
},
},
};
// `data` is loaded separately below, so new rows don't rebuild the grid.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columns, features, actions, title]);
useEffect(() => {
gridRef.current?.getStore().loadData(data);
}, [data]);
useImperativeHandle(ref, () => ({
getSelection: selectedRows,
exportTo,
setData: (rows) => gridRef.current?.getStore().loadData(rows),
}));
return (
<ReExt
xtype="grid"
style={{ height }}
config={config}
ready={(cmp: any) => { gridRef.current = cmp; }}
/>
);
});
export default EnterpriseGrid;
Three design choices are worth calling out:
- Stable configuration. The Ext JS config is rebuilt only when columns, features, actions or title change. New data is loaded into the existing store, so the user keeps their scroll position, sort order and column widths.
- Latest-props ref. Event handlers are created once but always call the newest callbacks from props, so a parent re-render never leaves the grid calling stale functions.
- Selection-aware actions. Buttons marked requiresSelection start disabled and switch on as soon as a row is selected, using an Ext JS component query on a custom flag.
Step 3: Use it on real screens
With the component in place, a fully featured screen is a column list, a set of flags and a few actions. Define all three outside the component so they stay stable between renders.
An order approval screen
import { useMemo, useState } from 'react';
import EnterpriseGrid, { GridAction, Row } from '../components/grid/EnterpriseGrid';
import { approveOrders, reloadOrders, saveOrder } from '../api/orders'; // your API calls
import type { GridColumn } from '../components/grid/gridColumns';
import type { GridFeatures } from '../components/grid/gridFeatures';
const columns: GridColumn[] = [
{ field: 'id', header: 'Order', width: 90 },
{ field: 'customer', header: 'Customer', flex: 1, summary: 'count' },
{ field: 'region', header: 'Region', width: 110, options: ['EMEA', 'APAC', 'AMER'] },
{ field: 'amount', header: 'Amount', type: 'currency', editable: true, required: true, summary: 'sum' },
{ field: 'orderDate', header: 'Order date', type: 'date' },
{ field: 'status', header: 'Status', editable: true, options: ['Open', 'Shipped', 'Closed'] },
];
const features: GridFeatures = {
groupBy: 'region',
summaries: true,
filters: true,
editing: 'cell',
lockColumns: 2,
exportable: true,
};
export function OrderApprovals({ orders }: { orders: Row[] }) {
const [count, setCount] = useState(orders.length);
const actions: GridAction[] = useMemo(() => [
{ id: 'approve', text: 'Approve', requiresSelection: true,
onClick: (rows) => approveOrders(rows.map((r) => r.id)) },
{ id: 'refresh', text: 'Refresh', onClick: () => reloadOrders() },
], []);
return (
<>
<p>{count} orders shown</p>
<EnterpriseGrid
title="Orders awaiting approval"
columns={columns}
data={orders}
features={features}
actions={actions}
exportFileName="orders-for-approval"
onCellEdit={({ row, field, value }) => saveOrder(row.id, { [field]: value })}
onViewChange={(view) => setCount(view.count)}
/>
</>
);
}
That screen gets grouping by region with subtotals and a grand total, typed filter menus, cell editing with validation and rollback, two locked columns, an Approve button that enables itself on selection, and an Excel and CSV export menu, without a single Ext JS plugin name in the screen’s code.
A read-only lookup screen
The same component, with different flags:
const customerFeatures: GridFeatures = { filters: true, exportable: true };
<EnterpriseGrid title="Customers" columns={customerColumns} data={customers} features={customerFeatures} />
Both screens share formatting, validation, theming and export behavior, because all of it lives in one component.
Step 4: Document and test it
A shared component earns trust when every feature flag is visible in the docs and protected by a test.
Storybook: one story per feature
Wrap stories in the same provider your apps use, so they render exactly as they will in production:
// EnterpriseGrid.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { UIProvider } from '../UIProvider';
import EnterpriseGrid from './EnterpriseGrid';
import { orderColumns } from './fixtures';
import { ordersIso } from '../../test-kit/data';
const meta: Meta<typeof EnterpriseGrid> = {
title: 'Data/EnterpriseGrid',
component: EnterpriseGrid,
decorators: [
(Story) => (
<UIProvider licenseKey={import.meta.env.STORYBOOK_REEXT_KEY}>
<Story />
</UIProvider>
),
],
args: { title: 'Orders', columns: orderColumns, data: ordersIso },
};
export default meta;
type Story = StoryObj<typeof EnterpriseGrid>;
export const Basic: Story = {};
export const Grouped: Story = { args: { features: { groupBy: 'region', summaries: true } } };
export const Filterable: Story = { args: { features: { filters: true } } };
export const Editable: Story = { args: { features: { editing: 'cell' } } };
export const LockedColumns: Story = { args: { features: { lockColumns: 2 } } };
export const WithActions: Story = {
args: {
actions: [{ id: 'approve', text: 'Approve', requiresSelection: true, onClick: (rows) => alert(rows.length) }],
},
};
UIProvider is the shared ReExt setup from our guide to standardizing on one UI component library, and ordersIso comes from the ReExt test kit’s dummy data.
Unit tests for the feature builder
Because buildGridConfig is a pure function, each flag can be tested without rendering a grid:
import { describe, it, expect } from 'vitest';
import { buildGridConfig } from './gridFeatures';
import type { GridColumn } from './gridColumns';
const cols: GridColumn[] = [
{ field: 'customer', header: 'Customer' },
{ field: 'amount', header: 'Amount', type: 'currency', editable: true },
{ field: 'status', header: 'Status', options: ['Open', 'Closed'] },
];
describe('buildGridConfig', () => {
it('adds grouping with summaries', () => {
const cfg = buildGridConfig(cols, { groupBy: 'region', summaries: true });
expect(cfg.features).toEqual([
{ ftype: 'groupingsummary' },
{ ftype: 'summary', dock: 'bottom' },
]);
});
it('maps filters to column types', () => {
const cfg = buildGridConfig(cols, { filters: true });
expect(cfg.columns.map((c) => c.filter)).toEqual([
'string', 'number', { type: 'list', options: ['Open', 'Closed'] },
]);
});
it('locks the first n columns', () => {
const cfg = buildGridConfig(cols, { lockColumns: 1 });
expect(cfg.columns.map((c) => c.locked ?? false)).toEqual([true, false, false]);
});
it('turns on spreadsheet selection and clipboard together', () => {
const cfg = buildGridConfig(cols, { spreadsheetSelection: true });
expect(cfg.selModel).toMatchObject({ type: 'spreadsheet' });
expect(cfg.plugins).toContainEqual({ ptype: 'clipboard' });
});
});
Add a few browser tests with Playwright or Cypress for behavior only a real grid can show: an edit that fails rolls back, an action enables on selection, and export downloads a file.
Roll it out
Publish EnterpriseGrid in your internal UI package with a changelog and semantic versioning. Add new flags only when several screens need them, and keep each one documented by a story and covered by a test.
Frequently asked questions
How do I make the Ext JS data grid a reusable React UI component?
Wrap it in a React component that exposes a small API: typed columns, feature flags, toolbar actions and plain-object events. Inside, map those props to Ext JS plugins and settings, and render the grid with ReExt. Screens then switch features on with flags instead of configuring the grid directly.
Why use feature flags instead of passing Ext JS configuration?
Flags keep screens simple and consistent. Every team gets the same behavior for filters: true or groupBy, and when the underlying configuration needs to change, it changes once inside the component instead of on every screen.
How do I add custom buttons to a reusable grid?
Pass them in as data. In this guide’s EnterpriseGrid, each action has an ID, a label, an onClick that receives the selected rows, and an optional requiresSelection flag that disables the button until a row is selected.
How do I keep the grid from resetting when data changes?
Keep the grid’s configuration memoized on structural props, such as columns and features, and load new rows into the existing store with loadData. Define columns, features and actions outside the component or memoize them.
How should I test a reusable grid component?
Unit-test the pure function that maps props to grid configuration, document every feature flag with a Storybook story, and add a few browser tests for behavior such as edit rollback, selection-aware actions and export.
A grid every team can use the same way
The Ext JS data grid already has the features enterprise screens need. Wrapping it in a reusable React UI component, with feature flags, action slots, typed events, stories and tests, turns that power into something every team uses the same way, and that you maintain in one place.
ReExt makes the Ext JS grid available inside React, so the component you build is a normal React component your design system can own, document and version.
Next steps: start a 30-day ReExt trial.
Ask a team with a ten-year-old Ext JS application how much of it is covered…
The data grid gets most of the attention in enterprise React apps, and for good…
Most teams choose a React UI component library by looking at buttons, inputs, themes and…




