From 2c473fc37e850fcf2d878f1d6f92beb693c8ddf5 Mon Sep 17 00:00:00 2001
From: noahisdai
Date: Thu, 20 Aug 2026 01:56:57 +0300
Subject: [PATCH 1/4] fix(selection): use the controlled selectedRows prop as
the delta base (#1375)
The reducer computed selection changes from internal state, which is never
reconciled with the controlled selectedRows prop, so a selection set from
outside the table was dropped on the next in-table toggle. Wrap the reducer
to substitute the prop into the state it computes from when controlled.
---
src/__tests__/DataTable.test.tsx | 36 ++++++++++++++++++++++++++++++++
src/hooks/useTableState.ts | 12 ++++++++++-
2 files changed, 47 insertions(+), 1 deletion(-)
diff --git a/src/__tests__/DataTable.test.tsx b/src/__tests__/DataTable.test.tsx
index bdd3f805..0d3e9336 100644
--- a/src/__tests__/DataTable.test.tsx
+++ b/src/__tests__/DataTable.test.tsx
@@ -255,6 +255,42 @@ describe('DataTable::onSelectedRowsChange', () => {
});
});
+describe('DataTable::controlled selectedRows', () => {
+ test('should call onSelectedRowsChange with the correct values when a row is toggled while another is selected via the controlled selectedRows prop', () => {
+ const mock = dataMock();
+ const onSelectedRowsChange = vi.fn();
+
+ const { container, rerender } = render(
+ ,
+ );
+
+ rerender(
+ ,
+ );
+
+ fireEvent.click(container.querySelector('input[name="Select row 2"]') as HTMLInputElement);
+
+ const calls = onSelectedRowsChange.mock.calls;
+ const lastCall = calls[calls.length - 1][0];
+ expect(lastCall.selectedCount).toBe(2);
+ expect(lastCall.selectedRows.map((row: { id: number }) => row.id).sort()).toEqual([1, 2]);
+ });
+});
+
describe('data prop changes', () => {
test('should update state if the data prop changes', () => {
const mock = dataMock();
diff --git a/src/hooks/useTableState.ts b/src/hooks/useTableState.ts
index c1842095..9de7dc08 100644
--- a/src/hooks/useTableState.ts
+++ b/src/hooks/useTableState.ts
@@ -93,7 +93,17 @@ export default function useTableState(props: UseTableStateProps): UseTable
const hasDefaultSort = defaultSortColumn.id != null || !!defaultSortColumn.selector;
- const [tableState, dispatch] = React.useReducer, Action>>(tableReducer, {
+ const reducer = React.useCallback(
+ (state: TableState, action: Action): TableState => {
+ const calculatedState =
+ controlledSelectedRows !== undefined ? { ...state, selectedRows: controlledSelectedRows } : state;
+
+ return tableReducer(calculatedState, action);
+ },
+ [controlledSelectedRows],
+ );
+
+ const [tableState, dispatch] = React.useReducer(reducer, {
allSelected: false,
selectedCount: 0,
selectedRows: [],
From 118c093a9c5d77731c77810c3252902184ab36a7 Mon Sep 17 00:00:00 2001
From: John Betancur <1385932+jbetancur@users.noreply.github.com>
Date: Wed, 19 Aug 2026 19:19:15 -0400
Subject: [PATCH 2/4] docs: add more row selection demos | selection demo
cleanup (#1376)
---
.../demos/ControlledSelectionDemo.tsx | 114 ++++++++++++
.../components/demos/CustomCheckboxDemo.tsx | 171 +++++++++++++++++
.../src/components/demos/VisibleOnlyDemo.tsx | 78 ++++++++
apps/docs/src/pages/docs/export.astro | 4 +-
apps/docs/src/pages/docs/inline-editing.astro | 5 -
apps/docs/src/pages/docs/selection.astro | 175 +++++++++++++++---
6 files changed, 510 insertions(+), 37 deletions(-)
create mode 100644 apps/docs/src/components/demos/ControlledSelectionDemo.tsx
create mode 100644 apps/docs/src/components/demos/CustomCheckboxDemo.tsx
create mode 100644 apps/docs/src/components/demos/VisibleOnlyDemo.tsx
diff --git a/apps/docs/src/components/demos/ControlledSelectionDemo.tsx b/apps/docs/src/components/demos/ControlledSelectionDemo.tsx
new file mode 100644
index 00000000..86337f06
--- /dev/null
+++ b/apps/docs/src/components/demos/ControlledSelectionDemo.tsx
@@ -0,0 +1,114 @@
+import React, { useState } from 'react';
+import DataTable from '../ThemedDataTable';
+import { type TableColumn } from 'react-data-table-component';
+
+interface Row {
+ id: number;
+ name: string;
+ role: string;
+ status: 'Active' | 'On Leave';
+}
+
+const data: Row[] = [
+ { id: 1, name: 'Aria Chen', role: 'Engineering Lead', status: 'Active' },
+ { id: 2, name: 'Marcus Webb', role: 'Product Manager', status: 'Active' },
+ { id: 3, name: 'Priya Kapoor', role: 'Senior Designer', status: 'On Leave' },
+ { id: 4, name: 'Jordan Ellis', role: 'Data Scientist', status: 'Active' },
+ { id: 5, name: 'Sam Rivera', role: 'DevOps Engineer', status: 'On Leave' },
+];
+
+const columns: TableColumn[] = [
+ { name: 'Name', selector: r => r.name, sortable: true },
+ { name: 'Role', selector: r => r.role },
+ { name: 'Status', selector: r => r.status },
+];
+
+export default function ControlledSelectionDemo() {
+ const [selected, setSelected] = useState([]);
+ const [events, setEvents] = useState([]);
+
+ const setExternally = (rows: Row[], label: string) => {
+ setSelected(rows);
+ setEvents(prev => [`external: ${label}`, ...prev].slice(0, 6));
+ };
+
+ const selectWhere = (label: string, predicate: (row: Row) => boolean) =>
+ setExternally(data.filter(predicate), label);
+
+ const invert = () =>
+ setExternally(
+ data.filter(row => !selected.some(s => s.id === row.id)),
+ 'invert selection',
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+ The buttons write straight to the selectedRows state. Checking a box in the table
+ calls onSelectedRowsChange, which writes back to that same state. Both paths stay in sync.
+
- New in 8.1.0 — useTableExport ships as part of the library's
- headless surface alongside useTableState, useTableData, and friends.
+ useTableExport ships as part of the library's headless surface
+ alongside useTableState, useTableData, and friends.
-
- New in 8.1.0 — number, date, checkbox, and custom editors plus the
- validate hook. The text and select editors are unchanged.
-
-
@@ -15,11 +18,6 @@ import SelectionDemo from '../../components/demos/SelectionDemo.tsx';
selection from outside, or use the imperative ref API to clear it.
-
- New in 8.1.0 — Shift-click range selection (on by default) and a
- controlled selectedRows prop.
-
-
⚠ keyField is required for reliable selection
@@ -104,14 +102,6 @@ export default function App() {
-
Custom selection toolbar
-
- v8 removed the built-in contextMessage and contextActions props.
- Use onSelectedRowsChange to drive your own toolbar rendered outside the table —
- you get full control over layout, copy, and actions. See the
- bulk-action toolbar recipe for a complete example.
-
-
Range selection (Shift-click)
Click one row's checkbox, then Shift-click another to toggle every row in between to match
@@ -128,6 +118,27 @@ export default function App() {
// Disable range selection
`} />
+
Uncontrolled selection (default)
+
+ Omit selectedRows and the table owns selection itself. Read it via
+ onSelectedRowsChange, and clear it imperatively through the ref. This is the
+ demo at the top of this page, and it is what you want unless something outside the table
+ needs to set the selection.
+
+ (null);
+
+ setCount(selectedRows.length)}
+/>
+
+// clear it from outside
+ref.current?.clearSelectedRows();`} />
+
Controlled selection
Pass selectedRows to drive selection from your own state. The table will
@@ -135,25 +146,53 @@ export default function App() {
toggles. Match rows by keyField, so the entries you pass in must include the
key field.
-
+ Reach for this when something other than the table needs to set the selection: a "select all
+ matching" action, selection restored from URL or saved state, or a second view that has to
+ stay in sync. The demo below drives it from filter predicates.
+
+ ([]);
+ const selectWhere = (predicate) => setSelected(data.filter(predicate));
+
return (
- setSelected(selectedRows)}
- />
+ <>
+
+
+
+
+
+ setSelected(selectedRows)}
+ />
+ >
);
-}`} />
+}`}
+ >
+
+
+
Controlled selection is useful when selection lives in URL state, a Redux/Zustand store,
or needs to survive remounts. Omit selectedRows to fall back to the table's
internal state.
+
+ Write the emitted rows back to the same state you pass to selectedRows. The
+ table computes each toggle against the prop, so a value you set from outside is preserved
+ when the user checks another box.
+
Single select
Pass selectableRowsSingle to restrict to one row at a time. Shift-click range selection is automatically disabled in single-select mode.
@@ -170,6 +209,38 @@ export default function App() {
Pre-select rows
row.status === 'Active'} />;`} />
+
Select only visible rows
+
+ When pagination is enabled, selectableRowsVisibleOnly makes the "select all"
+ checkbox operate only on the current page rather than the full dataset.
+
+
+ It also changes what happens when the page changes: the selection is cleared. That keeps
+ "select all" honest — it always refers to the rows in front of you — but it means a
+ selection cannot be accumulated across pages. Leave the prop off if you need that.
+
+ ;`} />
+
+
+
+// Page-scoped: select all takes the current page, paging clears the selection
+`}
+ >
+
+
+
onSelectedRowsChange
The callback receives {`{ allSelected, selectedCount, selectedRows }`}.
+ v8 removed the built-in contextMessage and contextActions props.
+ Use onSelectedRowsChange to drive your own toolbar rendered outside the table —
+ you get full control over layout, copy, and actions. See the
+ bulk-action toolbar recipe for a complete example.
+
+
Highlight selected rows
Add selectableRowsHighlight to apply the theme's selected-row background
@@ -201,13 +280,6 @@ tableRef.current?.clearSelectedRows();`} />
;`} />
-
Select only visible rows
-
- When pagination is enabled, selectableRowsVisibleOnly makes the "select all"
- checkbox operate only on the current page rather than the full dataset.
-
- ;`} />
-
Custom checkbox component
Replace the built-in checkbox with your own component via selectableRowsComponent.
@@ -216,6 +288,49 @@ tableRef.current?.clearSelectedRows();`} />
;`} />
+
+
+ Your component receives checked, disabled, name,
+ onClick, and a ref. Forward that ref to a real
+ <input type="checkbox"> — DataTable sets .indeterminate on the
+ node directly for the partial-selection state on the header checkbox. Keep the input in the
+ DOM and style a sibling element off it, rather than replacing it, so keyboard and screen
+ reader behaviour still work.
+
A prop value can also be a function — it is called with the checkbox's indeterminate state
(true for the header checkbox when only some rows are selected) and its return
From b59d0ee502581ba8289c67f9d271e9f58293e315 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Wed, 19 Aug 2026 23:21:07 +0000
Subject: [PATCH 3/4] chore: release v8.8.1 [skip ci]
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index bffbfd94..c2a242a7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "react-data-table-component",
- "version": "8.8.0",
+ "version": "8.8.1",
"description": "A fast, feature-rich React data table. Working table in 10 lines.",
"funding": [
{
From f272a41c91cbb531fa1463a8ccef910e90779220 Mon Sep 17 00:00:00 2001
From: John Betancur <1385932+jbetancur@users.noreply.github.com>
Date: Wed, 19 Aug 2026 19:27:29 -0400
Subject: [PATCH 4/4] docs:update changelog
---
CHANGELOG.md | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e3ebed3f..245013a2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,14 @@
A summary of notable changes per release. For the full commit history see the [repository on GitHub](https://github.com/jbetancur/react-data-table-component/commits/master).
+## 8.8.1
+
+### Bug fixes
+
+- Controlled `selectedRows` is no longer dropped when a row is toggled in-table. Selection deltas are computed against the `selectedRows` prop instead of stale internal state, so a selection set from outside survives the next checkbox click and `onSelectedRowsChange` emits the correct set. → [Row selection](/docs/selection) ([#1374](https://github.com/jbetancur/react-data-table-component/issues/1374))
+
+---
+
## 8.8.0
### New features