A flexible, high-performance React data grid component with TypeScript support, advanced filtering, pagination, sorting, and customizable theming
npm install @reactorui/datagrid


A high-performance, feature-rich React data grid component with TypeScript support, pagination, and advanced filtering capabilities. Designed as a controlled presentation component for maximum flexibility.
- ๐ High Performance - Optimized rendering with memoization
- ๐ Advanced Filtering - Type-aware filters with multiple operators (string, number, date, boolean)
- ๐ Flexible Data Sources - Works with any data fetching strategy (REST, GraphQL, local)
- ๐ฑ Responsive Design - Mobile-first with touch-friendly interactions
- ๐จ Fully Customizable Theming - Pass custom theme objects to match your design system
- ๐ Dark Mode Ready - Built-in dark mode support with zinc palette option
- โฟ Accessibility First - WCAG compliant with keyboard navigation and ARIA labels
- ๐ง TypeScript Native - Full type safety and comprehensive IntelliSense support
- ๐ฏ Rich Event System - 20+ events covering every user interaction
- ๐ Granular Loading States - Action-specific loading indicators
- ๐ Scrollable Layout - Fixed headers with maxHeight and stickyHeader props
- ๐ฅ Load More Support - Incremental data loading with continuation tokens
- โก Zero Dependencies - Only React as peer dependency
``bash`
npm install @reactorui/datagridor
yarn add @reactorui/datagridor
pnpm add @reactorui/datagrid
`tsx
import { DataGrid } from '@reactorui/datagrid';
const data = [
{ id: 1, name: 'John Doe', email: 'john@example.com', age: 28 },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com', age: 34 },
];
function App() {
return
}
`
`tsx
import { DataGrid, Column } from '@reactorui/datagrid';
interface User {
id: number;
name: string;
email: string;
status: 'active' | 'inactive';
joinDate: string;
}
const columns: Column
{
key: 'name',
label: 'Full Name',
sortable: true,
render: (value, row) => (
{value.charAt(0)}
{value}
),
},
{ key: 'email', label: 'Email Address', sortable: true },
{
key: 'status',
label: 'Status',
dataType: 'string',
render: (status) => (
className={px-2 py-1 text-xs font-medium rounded-full ${
status === 'active' ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
}}
>
{status}
),
},
{
key: 'joinDate',
label: 'Join Date',
dataType: 'date',
sortable: true,
},
];
function App() {
return (
columns={columns}
variant="bordered"
size="lg"
enableSelection={true}
onSelectionChange={(selected) => console.log('Selected:', selected)}
/>
);
}
`
The DataGrid supports full theme customization via the theme prop. You can override any part of the default theme to match your design system.
`tsx
interface Theme {
// Container
container: string;
// Table
table: string;
header: string;
headerCell: string;
row: string;
cell: string;
selectedRow: string;
// Controls
searchInput: string;
select: string;
button: string;
buttonSecondary: string;
buttonDanger: string;
// Text
text: string;
textMuted: string;
textError: string;
// Pagination
pagination: string;
paginationButton: string;
paginationText: string;
// States
loadingSkeleton: string;
emptyState: string;
errorState: string;
// Filter
filterDropdown: string;
filterTag: string;
filterTagRemove: string;
}
`
`tsx
import { DataGrid, Theme } from '@reactorui/datagrid';
// Define your custom theme (partial overrides supported)
const myTheme: Partial
container: 'bg-white dark:bg-zinc-900 rounded-xl border border-zinc-200 dark:border-zinc-700',
row: 'bg-white dark:bg-zinc-900 hover:bg-zinc-50 dark:hover:bg-zinc-800',
cell: 'px-4 py-3 text-sm text-gray-900 dark:text-zinc-100 border-b dark:border-zinc-700',
searchInput:
'px-3 py-2 border dark:border-zinc-700 rounded-lg dark:bg-zinc-800 dark:text-zinc-100',
pagination:
'flex items-center justify-between px-4 py-3 dark:bg-zinc-900 border-t dark:border-zinc-700',
};
function App() {
return
}
`
For projects using the zinc color palette (like Tailwind's neutral grays), use the built-in helper:
`tsx
import { DataGrid, createZincTheme } from '@reactorui/datagrid';
// Creates a complete theme with zinc palette for dark mode
const zincTheme = createZincTheme('default'); // or 'striped' or 'bordered'
function App() {
return
}
`
Custom themes merge with the selected variant:
`tsx`
// Striped variant + custom zinc dark mode
variant="striped"
theme={{
container: 'bg-white dark:bg-zinc-900 rounded-xl',
row: 'odd:bg-white dark:odd:bg-zinc-900 even:bg-zinc-50 dark:even:bg-zinc-800/50',
}}
/>
Example integrating with a custom ThemeStyles system:
`tsx
// utils/dataGridTheme.ts
import { Theme } from '@reactorui/datagrid';
export const appDataGridTheme: Partial
container:
'bg-white dark:bg-zinc-900 rounded-lg shadow-sm border border-gray-200 dark:border-zinc-700',
table: 'w-full bg-white dark:bg-zinc-900',
header: 'bg-gray-50 dark:bg-zinc-800 border-b border-gray-200 dark:border-zinc-700',
headerCell: 'px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-zinc-400 uppercase',
row: 'bg-white dark:bg-zinc-900 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors',
cell: 'px-4 py-3 text-sm text-gray-900 dark:text-zinc-100 border-b dark:border-zinc-700',
selectedRow: 'bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30',
searchInput:
'px-3 py-2 border dark:border-zinc-700 rounded-md dark:bg-zinc-800 dark:text-zinc-100',
select: 'px-2 py-1 border dark:border-zinc-700 rounded dark:bg-zinc-800 dark:text-zinc-100',
button: 'px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700',
buttonSecondary:
'px-3 py-2 bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 rounded-md',
text: 'text-gray-700 dark:text-zinc-300',
textMuted: 'text-gray-500 dark:text-zinc-500',
textError: 'text-red-600 dark:text-red-400',
pagination:
'flex items-center justify-between px-4 py-3 dark:bg-zinc-900 border-t dark:border-zinc-700',
paginationButton: 'px-3 py-1 text-sm border dark:border-zinc-700 rounded dark:bg-zinc-800',
paginationText: 'text-sm text-gray-700 dark:text-zinc-300',
emptyState: 'text-gray-500 dark:text-zinc-400',
filterDropdown:
'absolute z-50 mt-2 bg-white dark:bg-zinc-800 border dark:border-zinc-700 rounded-lg shadow-lg',
filterTag:
'inline-flex items-center gap-1 px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-md',
};
// Usage
import { appDataGridTheme } from './utils/dataGridTheme';
`
The DataGrid is a controlled presentation component. You handle data fetching; the grid handles display.
`tsx
import { useState, useEffect } from 'react';
import { DataGrid, LoadingState, ActiveFilter, SortConfig } from '@reactorui/datagrid';
function ServerSideExample() {
const [data, setData] = useState([]);
const [loadingState, setLoadingState] = useState
const [totalRecords, setTotalRecords] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [error, setError] = useState
// Fetch data from your API
const fetchData = async (page: number, size: number, filters: ActiveFilter[], search: string) => {
setLoadingState({ data: true });
setError(null);
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page, pageSize: size, filters, search }),
});
const result = await response.json();
setData(result.items);
setTotalRecords(result.totalRecords);
} catch (err) {
setError('Failed to load data');
} finally {
setLoadingState({});
}
};
useEffect(() => {
fetchData(1, pageSize, [], '');
}, []);
return (
loadingState={loadingState}
totalRecords={totalRecords}
currentPage={currentPage}
error={error}
pageSize={pageSize}
paginationMode="server"
filterMode="server"
// Pagination callbacks - YOU handle the server call
onPageChange={(page) => {
setCurrentPage(page);
fetchData(page, pageSize, [], '');
}}
onPageSizeChange={(size) => {
setPageSize(size);
setCurrentPage(1);
fetchData(1, size, [], '');
}}
// Filter callbacks - YOU handle the server call
onApplyFilter={(filter, allFilters) => {
setLoadingState({ filter: true });
fetchData(1, pageSize, allFilters, '');
}}
onClearFilters={() => {
fetchData(1, pageSize, [], '');
}}
onSearchChange={(term) => {
setLoadingState({ search: true });
fetchData(1, pageSize, [], term);
}}
onTableRefresh={() => {
setLoadingState({ refresh: true });
fetchData(currentPage, pageSize, [], '');
}}
/>
);
}
`
Instead of a single loading boolean, use loadingState for action-specific feedback:
`tsx
interface LoadingState {
data?: boolean; // Shows skeleton, disables all controls
filter?: boolean; // Spinner on "Apply Filter" button only
search?: boolean; // Spinner in search input only
refresh?: boolean; // Spinner on refresh button only
delete?: boolean; // Spinner on delete button only
}
// Usage
loadingState={{ filter: true }} // Only Apply Filter shows spinner
/>
// Backward compatible - still works
`
Visual Result:
- Click "Apply Filter" โ only that button shows [โณ Applying...][โณ Deleting...]
- Click "Refresh" โ only that button spins
- Click "Delete" โ only that button shows
- Initial load โ table skeleton, all controls disabled
For large datasets, enable scrollable body with fixed headers:
`tsx
// Fixed pixel height
// Viewport-relative height
// Dynamic height
// Just sticky headers (browser determines scroll)
// Numeric value (converted to pixels)
`
For large datasets where you don't want to fetch everything upfront, use the Load More pattern. The button appears in the toolbar when more data is available.
`tsx
import { useState } from 'react';
import { DataGrid } from '@reactorui/datagrid';
function IncrementalLoadExample() {
const [data, setData] = useState([]);
const [continuationToken, setContinuationToken] = useState
const [loadingMore, setLoadingMore] = useState(false);
const handleLoadMore = async () => {
setLoadingMore(true);
try {
const result = await fetchData({ continuationToken });
setData((prev) => [...prev, ...result.items]); // Append to existing
setContinuationToken(result.continuationToken); // null = no more data
} finally {
setLoadingMore(false);
}
};
return (
enableLoadMore={true}
hasMore={continuationToken !== null}
loadingMore={loadingMore}
onLoadMore={handleLoadMore}
pageSize={25} // Pagination still works on loaded data
/>
);
}
`
Key Points:
- Button location: Appears in the toolbar (left of search) when enableLoadMore && hasMore
- Pagination unaffected: Client-side pagination works normally on loaded data
- Parent manages state: You control data array, continuation token, and loading state
- No total required: Works with APIs that only return a continuation token
`tsx`
enableFilters={true}
filterMode="server"
// Called when "Apply Filter" is clicked
onApplyFilter={(filter, allFilters) => {
console.log('New filter:', filter);
console.log('All active filters:', allFilters);
// Make your API call here
}}
// Called when a filter tag X is clicked
onRemoveFilter={(removedFilter, remainingFilters) => {
console.log('Removed:', removedFilter);
// Refetch with remaining filters
}}
// Called when "Clear All" is clicked
onClearFilters={() => {
console.log('All filters cleared');
// Refetch without filters
}}
// Called on any filter change (convenience callback)
onFilterChange={(filters) => {
console.log('Filters changed:', filters.length);
}}
/>
`tsxPage ${page} of ${paginationInfo.totalPages}
onPageChange={(page, paginationInfo) => {
console.log();Now showing ${pageSize} per page
}}
onPageSizeChange={(pageSize) => {
console.log();Sorted by ${sortConfig.column} ${sortConfig.direction}
}}
onSortChange={(sortConfig) => {
console.log();Searching: "${searchTerm}"
}}
onSearchChange={(searchTerm) => {
console.log();`
}}
onTableRefresh={() => {
console.log('Refresh clicked');
}}
/>
`tsx${row.name} ${isSelected ? 'selected' : 'deselected'}
onTableRowClick={(row, event) => {
console.log('Clicked:', row);
}}
onTableRowDoubleClick={(row, event) => {
openEditModal(row);
return false; // Prevent selection toggle
}}
onTableRowHover={(row, event) => {
row ? showTooltip(row) : hideTooltip();
}}
onRowSelect={(row, isSelected) => {
console.log();mailto:${value}
}}
onSelectionChange={(selectedRows) => {
setBulkActionsEnabled(selectedRows.length > 0);
}}
onCellClick={(value, row, column, event) => {
if (column.key === 'email') {
window.open();`
}
}}
/>
`tsx`
enableSelection={true}
enableDelete={true}
deleteConfirmation={true} // Shows confirm dialog
loadingState={{ delete: isDeleting }}
onBulkDelete={async (selectedRows) => {
setLoadingState({ delete: true });
await deleteUsers(selectedRows.map((r) => r.id));
setLoadingState({});
refetchData();
}}
/>
| Prop | Type | Default | Description |
| -------------- | ---------------- | ------------- | -------------------------------------- |
| data | T[] | Required | Array of data to display |columns
| | Column | Auto-detected | Column definitions |loading
| | boolean | false | Simple loading state (backward compat) |loadingState
| | LoadingState | {} | Granular loading states |totalRecords
| | number | - | Total records for pagination display |currentPage
| | number | - | Controlled current page |error
| | string \| null | - | Error message to display |
| Prop | Type | Default | Description |
| -------------- | -------------------------------------- | ----------- | ------------------------------------ |
| maxHeight | string \| number | - | Fixed height with scrollable body |stickyHeader
| | boolean | false | Enable sticky table header |className
| | string | '' | Additional CSS classes |variant
| | 'default' \| 'striped' \| 'bordered' | 'default' | Visual theme variant |size
| | 'sm' \| 'md' \| 'lg' | 'md' | Padding/text size |theme
| | Partial | - | Custom theme overrides (see Theming) |
| Prop | Type | Default | Description |
| -------------------- | ----------------------------------------- | ---------- | --------------------- |
| enableSearch | boolean | true | Show search input |enableSorting
| | boolean | true | Enable column sorting |enableFilters
| | boolean | true | Show filter controls |enableSelection
| | boolean | true | Show row checkboxes |enableDelete
| | boolean | false | Show delete button |enableRefresh
| | boolean | false | Show refresh button |deleteConfirmation
| | boolean | false | Confirm before delete |paginationMode
| | 'client' \| 'server' | 'client' | Pagination behavior |filterMode
| | 'client' \| 'server' \| 'client&server' | 'client' | Filter behavior |
| Prop | Type | Default | Description |
| ---------------- | ------------ | ------- | --------------------------------------- |
| enableLoadMore | boolean | false | Show "Load More" button in toolbar |hasMore
| | boolean | false | Whether more data is available to load |loadingMore
| | boolean | false | Show loading spinner on button |onLoadMore
| | () => void | - | Called when Load More button is clicked |
paginationMode options:
- 'client' (default) - Slices data locally, totalRecords is display-only'server'
- - No local slicing, parent handles pagination via onPageChange/onPageSizeChange
filterMode options:
- 'client' - Filters locally, no callbacks fired'server'
- - Fires callbacks only, no local filtering'client&server'
- - Filters locally AND fires callbacks
| Prop | Type | Default | Description |
| ----------------- | ---------- | ------------------ | -------------------------- |
| pageSize | number | 10 | Items per page |pageSizeOptions
| | number[] | [5,10,25,50,100] | Page size dropdown options |
| Event | Signature | Description |
| ----------------------- | ------------------------------------- | -------------------- |
| onApplyFilter\* | (filter, allFilters) => void | Filter applied |onRemoveFilter
| \* | (removed, remaining) => void | Filter tag removed |onClearFilters
| \* | () => void | Clear All clicked |onFilterChange
| \* | (filters) => void | Any filter change |onSearchChange
| | (term) => void | Search input changed |onSortChange
| | (sortConfig) => void | Column sort changed |onPageChange
| | (page, info) => void | Page navigation |onPageSizeChange
| | (size) => void | Page size changed |onTableRefresh
| | () => void | Refresh clicked |onTableRowClick
| | (row, event) => void | Row clicked |onTableRowDoubleClick
| | (row, event) => boolean \| void | Row double-clicked |onTableRowHover
| | (row \| null, event) => void | Row hover |onRowSelect
| | (row, isSelected) => void | Single row selection |onSelectionChange
| | (rows) => void | Selection changed |onCellClick
| | (value, row, column, event) => void | Cell clicked |onBulkDelete
| | (rows) => void | Delete clicked |onLoadMore
| | () => void | Load More clicked |
_\* Filter callbacks only fire when filterMode="server" or filterMode="client&server"_
`tsx`
interface Column
key: keyof T | string;
label: string;
sortable?: boolean; // Default: true
filterable?: boolean; // Default: true
dataType?: 'string' | 'number' | 'boolean' | 'date' | 'datetime';
width?: string | number;
minWidth?: string | number;
maxWidth?: string | number;
align?: 'left' | 'center' | 'right';
render?: (value: any, row: T, index: number) => ReactNode;
}
`tsx
// Clean, minimal design
// Alternating row colors
// Full borders around cells
// Size variants
// Dark mode (automatic with Tailwind)
$3
`tsx
import { DataGrid, createZincTheme } from '@reactorui/datagrid';// Option 1: Use zinc theme helper
// Option 2: Partial overrides
data={data}
theme={{
container: 'bg-white dark:bg-slate-900 rounded-2xl',
row: 'hover:bg-slate-50 dark:hover:bg-slate-800',
}}
/>
// Option 3: Complete custom theme
`๐งช Testing
`bash
npm test # Run tests
npm run test:watch # Watch mode
npm run test:coverage # Coverage report
``tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { DataGrid } from '@reactorui/datagrid';test('handles filter application', async () => {
const onApplyFilter = jest.fn();
render(
data={testData}
enableFilters={true}
filterMode="server"
onApplyFilter={onApplyFilter}
/>
);
// Select column, enter value, click Apply
const selects = screen.getAllByRole('combobox');
fireEvent.change(selects[0], { target: { value: 'name' } });
const input = screen.getByPlaceholderText('Enter value');
fireEvent.change(input, { target: { value: 'John' } });
fireEvent.click(screen.getByRole('button', { name: /apply filter/i }));
expect(onApplyFilter).toHaveBeenCalledWith(
expect.objectContaining({ column: 'name', value: 'John' }),
expect.any(Array)
);
});
test('applies custom theme', () => {
const customTheme = { container: 'custom-class dark:bg-zinc-900' };
const { container } = render( );
expect(container.firstChild).toHaveClass('dark:bg-zinc-900');
});
test('client mode slices data locally', () => {
const largeData = Array.from({ length: 50 }, (_, i) => ({ id: i, name:
User ${i} }));
render( ); // Should only show 10 rows
const rows = screen.getAllByRole('row');
expect(rows.length - 1).toBe(10); // minus header
});
test('server mode does not slice data', () => {
const pageData = Array.from({ length: 5 }, (_, i) => ({ id: i, name:
User ${i} }));
render( ); // Shows all 5 rows (server already sliced)
const rows = screen.getAllByRole('row');
expect(rows.length - 1).toBe(5);
// But displays totalRecords
expect(screen.getByText(/of 100 records/)).toBeInTheDocument();
});
test('shows Load More button when enabled and hasMore', () => {
const onLoadMore = jest.fn();
render( );
const loadMoreButton = screen.getByText('Load More');
expect(loadMoreButton).toBeInTheDocument();
fireEvent.click(loadMoreButton);
expect(onLoadMore).toHaveBeenCalledTimes(1);
});
test('hides Load More button when hasMore is false', () => {
render( );
expect(screen.queryByText('Load More')).not.toBeInTheDocument();
});
`โ ๏ธ Migration Guide
$3
The following props are deprecated and will show console warnings. They will be removed in the next major version:
| Deprecated Prop | Replacement | Notes |
| ---------------------- | ------------------- | ----------------------------------- |
|
endpoint | Use data prop | Fetch data in parent, pass to grid |
| httpConfig | Use data prop | Handle auth/headers in parent fetch |
| serverPageSize | pageSize | Use standard pageSize prop |
| onDataLoad | Use data prop | Handle in parent after fetch |
| onDataError | error prop | Pass error message as prop |
| onLoadingStateChange | loadingState prop | Use granular loading states |$3
`tsx
// โ Deprecated approach
endpoint="/api/users"
httpConfig={{ bearerToken: 'xxx' }}
serverPageSize={100}
onDataLoad={(res) => console.log(res)}
onDataError={(err) => console.error(err)}
/>
`$3
`tsx
// โ
Recommended approach
function MyGrid() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null); const fetchData = async () => {
setLoading(true);
try {
const res = await fetch('/api/users', {
headers: { Authorization: 'Bearer xxx' },
});
setData(await res.json());
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
return ;
}
`$3
Breaking: Filter callbacks now require
filterMode to be set:`tsx
// โ Won't fire callbacks (filterMode defaults to 'client')
data={data}
onApplyFilter={(f) => console.log(f)} // Never called!
/>// โ
Set filterMode to enable callbacks
data={data}
filterMode="server" // or "client&server"
onApplyFilter={(f) => fetchWithFilter(f)}
/>
`$3
totalRecords no longer automatically triggers server-side pagination. Use explicit paginationMode:`tsx
// Client-side pagination (default) - totalRecords is display only
data={allData}
totalRecords={500} // Just for display: "Showing 1-25 of 500"
pageSize={25}
// paginationMode="client" is the default
/>// Server-side pagination - parent handles slicing
data={currentPageData}
totalRecords={500}
paginationMode="server" // Explicit opt-in
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
/>
`๐ง Development
`bash
npm install # Install dependencies
npm test # Run tests
npm run build # Build library
npm run typecheck # Type checking
npm run lint # Linting
`๐ค Publishing
`bash
npm run build # Build the package
npm version patch|minor|major # Bump version
npm publish --access public # Publish to npm
``MIT License - see LICENSE file for details.
Part of the ReactorUI ecosystem:
- ๐ @reactorui/recurrence - Recurrence rule builder
- ๐ More components coming soon!
---
Made with โค๏ธ by ReactorUI