Missed JavaScript Days 2026? The Replays Are Now Available – Watch Now!

Building a Reusable React UI Component Around the Ext JS Data Grid

Oct 29, 2026 109 Views

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.

Building a Reusable React UI Component Around the Ext JS Data Grid

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:

  1. Features as flags, not configuration. Screens say filters: true or groupBy: ‘region’; the component knows which plugins and settings that means.
  2. 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.
  3. Plain, typed events. Selections, edits and view changes come out as ordinary objects, never Ext JS records.
  4. A small escape hatch. A ref exposes a few methods for the rare cases the props don’t cover.
  5. 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.

Sencha CTA Banner: Try Sencha ReExt

Author

Team Sencha

Team Sencha is a team of software developers, technology experts, and product specialists with deep experience in building and delivering enterprise-grade web applications. Through our articles, we share practical insights, technical expertise, and industry perspectives to help developers and businesses build better, faster, and more scalable applications with Sencha technologies.