Performance
react-data-table-component is heavily memoized. Cell and column components only re-render when their own props change. Most performance problems we see come from identity churn in props passed to DataTable, not from the library itself. This page covers the patterns that matter.
Row count guidance
The table does not virtualize rows. Every row in data renders to the DOM. As a rough guide:
| Row count | Behavior |
|---|---|
| < 500 | No performance considerations. Render as-is. |
| 500 – 5,000 | Enable pagination with a reasonable paginationPerPage (25–100). Sort and filter stay fast. |
| 5,000 – 50,000 | Use paginationServer and sortServer. Don't pass the full dataset to DataTable. Slice on the server and send only the visible page. |
| > 50,000 | Always server-side. Consider whether a table is the right UI at this scale. Search or visualization tools are usually a better fit. |
Try it yourself
Don't take the table's word for it. This demo generates the dataset in your browser and times every interaction: data generation, sorting, filtering, and page changes. You can see exactly where client-side handling stops being free on your hardware.
Stress test
Pick a row and column count, then sort, filter, and paginate. The readout shows generation time and click-to-render time for the last interaction.
Sorting and filtering always run over the full dataset. Click a column header or filter Name at 100,000 rows to see the cost. Only the current page renders to the DOM; switch to 500 rows per page to stress actual DOM size. Each feature toggle remounts its extra cells and handlers, so flip them at 500 rows per page to see what they cost. Try dragging a column edge (Resizable) or a header (Column reorder), and use arrow keys after clicking a cell (Keyboard nav).
import { useEffect, useMemo, useRef, useState } from 'react';
import DataTable from '../ThemedDataTable';
import { type TableColumn } from 'react-data-table-component';
type Row = {
id: number;
name: string;
department: string;
region: string;
salary: number;
score: number;
[metric: `m${number}`]: number;
};
const FIRST = ['Aria', 'Marcus', 'Priya', 'Jordan', 'Sam', 'Taylor', 'Casey', 'Alex', 'Morgan', 'Drew', 'Riley', 'Jamie'];
const LAST = ['Chen', 'Webb', 'Kapoor', 'Ellis', 'Rivera', 'Brooks', 'Morgan', 'Kim', 'Lee', 'Park', 'Nguyen', 'Okafor'];
const DEPTS = ['Engineering', 'Product', 'Design', 'Analytics', 'Sales', 'HR'];
const REGIONS = ['NA', 'EMEA', 'APAC', 'LATAM'];
// Deterministic PRNG so every visitor stresses the same dataset
function mulberry32(seed: number) {
return () => {
seed |= 0;
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function generate(rowCount: number, metricCount: number): Row[] {
const rand = mulberry32(rowCount * 31 + metricCount);
const rows: Row[] = new Array(rowCount);
for (let i = 0; i < rowCount; i++) {
const row: Row = {
id: i + 1,
name: `${FIRST[(rand() * FIRST.length) | 0]} ${LAST[(rand() * LAST.length) | 0]}`,
department: DEPTS[(rand() * DEPTS.length) | 0],
region: REGIONS[(rand() * REGIONS.length) | 0],
salary: 45000 + ((rand() * 1200) | 0) * 100,
score: Math.round(rand() * 1000) / 10,
};
for (let m = 0; m < metricCount; m++) {
row[`m${m}`] = Math.round(rand() * 10000) / 100;
}
rows[i] = row;
}
return rows;
}
const ROW_OPTIONS = [1_000, 10_000, 50_000, 100_000];
const COL_OPTIONS = [6, 20, 40];
const fmt = (n: number) => n.toLocaleString('en-US');
type Features = {
resizable: boolean;
reorder: boolean;
selection: boolean;
expandable: boolean;
keyboardNav: boolean;
animations: boolean;
};
const FEATURE_TOGGLES: { key: keyof Features; label: string }[] = [
{ key: 'resizable', label: 'Resizable' },
{ key: 'reorder', label: 'Column reorder' },
{ key: 'selection', label: 'Selection' },
{ key: 'expandable', label: 'Expandable rows' },
{ key: 'keyboardNav', label: 'Keyboard nav' },
{ key: 'animations', label: 'Row animations' },
];
const Expander = ({ data }: { data: Row }) => (
<div className="px-6 py-3 text-xs text-gray-500">
{data.name} · {data.department} · {data.region} · ${fmt(data.salary)}
</div>
);
export default function PerformanceDemo() {
const [rowCount, setRowCount] = useState(10_000);
const [colCount, setColCount] = useState(6);
const [perPage, setPerPage] = useState(25);
const [features, setFeatures] = useState<Features>({
resizable: false,
reorder: false,
selection: false,
expandable: false,
keyboardNav: false,
animations: false,
});
const [lastPaint, setLastPaint] = useState<number | null>(null);
const interactionStart = useRef<number | null>(null);
const genMs = useRef(0);
const data = useMemo(() => {
const t0 = performance.now();
const rows = generate(rowCount, colCount - 6);
genMs.current = performance.now() - t0;
return rows;
}, [rowCount, colCount]);
const columns = useMemo<TableColumn<Row>[]>(() => {
const base: TableColumn<Row>[] = [
{ id: 'id', name: 'ID', selector: r => r.id, sortable: true, width: '80px' },
{ id: 'name', name: 'Name', selector: r => r.name, sortable: true, filterable: true, minWidth: '150px' },
{ id: 'department', name: 'Department', selector: r => r.department, sortable: true, filterable: true },
{ id: 'region', name: 'Region', selector: r => r.region, sortable: true, width: '90px' },
{ id: 'salary', name: 'Salary', selector: r => r.salary, sortable: true, right: true, format: r => `$${fmt(r.salary)}` },
{ id: 'score', name: 'Score', selector: r => r.score, sortable: true, right: true },
];
for (let m = 0; m < colCount - 6; m++) {
base.push({
id: `m${m}`,
name: `Metric ${m + 1}`,
selector: r => r[`m${m}`],
sortable: true,
right: true,
minWidth: '110px',
});
}
return features.reorder ? base.map(c => ({ ...c, reorder: true })) : base;
}, [colCount, features.reorder]);
// Measure click → commit for any interaction inside the demo (buttons,
// header sorts, pagination, filters). The effect runs after React commits.
useEffect(() => {
if (interactionStart.current === null) return;
const elapsed = performance.now() - interactionStart.current;
interactionStart.current = null;
setLastPaint(elapsed);
});
const markInteraction = () => {
interactionStart.current = performance.now();
};
const btnBase = 'px-2.5 py-1 rounded-md text-xs font-medium border transition-colors cursor-pointer';
const btnOn = 'bg-brand-600 text-white border-brand-600';
const btnOff = 'bg-white text-gray-600 border-gray-200 hover:border-gray-300';
return (
<div className="space-y-3" onClickCapture={markInteraction} onKeyDownCapture={markInteraction}>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">Rows</span>
{ROW_OPTIONS.map(n => (
<button
key={n}
className={`${btnBase} ${rowCount === n ? btnOn : btnOff}`}
onClick={() => setRowCount(n)}
>
{fmt(n)}
</button>
))}
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-400">Columns</span>
{COL_OPTIONS.map(n => (
<button
key={n}
className={`${btnBase} ${colCount === n ? btnOn : btnOff}`}
onClick={() => setColCount(n)}
>
{n}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-gray-400">Features</span>
{FEATURE_TOGGLES.map(({ key, label }) => (
<button
key={key}
className={`${btnBase} ${features[key] ? btnOn : btnOff}`}
onClick={() => setFeatures(prev => ({ ...prev, [key]: !prev[key] }))}
>
{label}
</button>
))}
</div>
</div>
<div className="text-xs text-gray-500 font-mono">
{fmt(rowCount)} rows × {colCount} cols ({fmt(rowCount * colCount)} cells) · generated in{' '}
{genMs.current.toFixed(0)}ms · {fmt(perPage)} rows in the DOM
{lastPaint !== null && <> · last interaction → render: {lastPaint.toFixed(0)}ms</>}
</div>
<DataTable
columns={columns}
data={data}
pagination
paginationPerPage={25}
paginationRowsPerPageOptions={[25, 100, 200, 300, 400, 500]}
onChangeRowsPerPage={n => setPerPage(n)}
fixedHeader
fixedHeaderScrollHeight="420px"
dense
highlightOnHover
resizable={features.resizable}
selectableRows={features.selection}
expandableRows={features.expandable}
expandableRowsComponent={Expander}
cellNavigation={features.keyboardNav}
animateRows={features.animations}
/>
<p className="text-xs text-gray-400">
Sorting and filtering always run over the full dataset. Click a column header or filter Name at 100,000
rows to see the cost. Only the current page renders to the DOM; switch to 500 rows per page to stress
actual DOM size. Each feature toggle remounts its extra cells and handlers, so flip them at 500 rows per
page to see what they cost. Try dragging a column edge (Resizable) or a header (Column reorder), and use
arrow keys after clicking a cell (Keyboard nav).
</p>
</div>
);
}Recommendations
Everything in this section is in your hands as a consumer of the library. The table memoizes its own internals; these patterns keep your side of the contract so that memoization actually holds.
The biggest footgun: new references every render
DataTable uses React.memo on its row and cell components. Memoization is broken by reference equality, not value equality. These patterns silently re-render every row on every parent render:
// ❌ New array literal every render → all rows re-render
function App({ items }) {
return (
<DataTable
columns={[
{ name: 'Name', selector: r => r.name }, // new function every render
]}
data={items}
/>
);
}
// ❌ Inline conditional row styles → new array → all rows re-render
<DataTable
conditionalRowStyles={[{ when: r => r.flagged, style: { color: 'red' } }]}
/* ... */
/>;Fix: hoist or memoize the references.
// ✅ Stable column array
const columns: TableColumn<Item>[] = [{ name: 'Name', selector: r => r.name }];
function App({ items }) {
return <DataTable columns={columns} data={items} />;
}
// ✅ Or memoize when it depends on state
const columns = useMemo<TableColumn<Item>[]>(
() => [
{ name: 'Name', selector: r => r.name },
{ name: 'Salary', selector: r => r.salary, omit: !showSalary },
],
[showSalary],
);Stabilize event handlers
Pass useCallback-wrapped handlers for any callback prop you read from inside a row (onRowClicked, onSelectedRowsChange, etc.). Without it, every render changes the handler identity and the row context updates downstream.
const handleRowClicked = useCallback(
(row: Employee) => {
navigate(`/employees/${row.id}`);
},
[navigate],
);
<DataTable columns={columns} data={data} onRowClicked={handleRowClicked} />;Stabilize the data array
A new data array re-runs sort, pagination slicing, and filter predicates. If your data comes from a server, store the response in state and only replace it on real updates. Don't recreate it during render.
// ❌ data is a new array every render
function App() {
const items = useStore(s => s.items.map(transform)); // new array
return <DataTable data={items} columns={columns} />;
}
// ✅ Compute once, memoize
function App() {
const rawItems = useStore(s => s.items);
const items = useMemo(() => rawItems.map(transform), [rawItems]);
return <DataTable data={items} columns={columns} />;
}Sorting is O(n log n) in the table
Client-side sort runs on the full data array on every sort change. For larger datasets, push sort to the server with sortServer. DataTable will skip its internal sort and call onSort(column, direction) instead.
<DataTable
columns={columns}
data={pageOfData}
sortServer
onSort={(column, direction) => refetch({ sortBy: column.id, sortDir: direction })}
/>;Server-side pagination
When pagination is server-driven, pass only the current page's rows in dataand the total row count in paginationTotalRows. The table treatsdata as "this page" and lets the pagination footer drive page changes:
<DataTable
columns={columns}
data={currentPageRows} // just the rows for this page
paginationServer
paginationTotalRows={total} // total across all pages
onChangePage={setPage}
onChangeRowsPerPage={setPerPage}
pagination
/>;Column filtering
Built-in filters run client-side on every keystroke. For large datasets, switch to controlled filters and debounce server calls:
import { useState, useEffect } from 'react';
const [filters, setFilters] = useState({});
const [debounced, setDebounced] = useState(filters);
useEffect(() => {
const t = setTimeout(() => setDebounced(filters), 250);
return () => clearTimeout(t);
}, [filters]);
useEffect(() => {
refetch({ filters: debounced });
}, [debounced]);
<DataTable
columns={columns}
data={data}
filterValues={filters}
onFilterChange={(columnId, next) => setFilters(prev => ({ ...prev, [columnId]: next }))}
/>;Cell renderers
A custom cell renderer runs once per cell on every row re-render. Keep them cheap. Don't allocate inside them. Compute style objects outside the renderer if possible:
// ❌ New style object every cell
{
cell: row => <span style={{ padding: 8, color: row.flagged ? 'red' : 'black' }}>{row.name}</span>;
}
// ✅ Static + conditional override
const baseStyle = { padding: 8 };
{
cell: row => <span style={row.flagged ? { ...baseStyle, color: 'red' } : baseStyle}>{row.name}</span>;
}Avoid expensive selectors
selector runs once per cell on render and once per row on every sort or filter. Keep them O(1):
// ❌ Walks an array every call
{
selector: r => r.tags.find(t => t.primary)?.name ?? '';
}
// ✅ Compute upstream, store on the row
const enriched = useMemo(() => rows.map(r => ({ ...r, primaryTag: r.tags.find(t => t.primary)?.name ?? '' })), [rows]);
{
selector: r => r.primaryTag;
}Animations
animateRows staggers row entrance and animates sort transitions. On very long pages (hundreds of rows) the cumulative animation cost can be visible. The animation is automatically disabled when the user has prefers-reduced-motion enabled.
Profiler checklist
If the table feels slow, open React DevTools → Profiler and check:
- Are individual row components re-rendering on parent-state changes that shouldn't affect them? → Some prop is changing identity. Check
columns,conditionalRowStyles, callbacks. - Is the body re-rendering on every keystroke in a controlled input outside the table? → Lift the keystroke state higher or memoize the table.
- Is sorting slow on click? → Move to
sortServer. - Is pagination slow on page change? → You're probably recomputing
dataevery render. Memoize it.