Select all in a million filtered rows, and send the server one line instead of a list
DEV Community

Select all in a million filtered rows, and send the server one line instead of a list

"How do I get the selected rows from a checkbox column?" has been read more than 82,000 times on Stack Overflow, and it is rarely about the checkbox. It comes up when a table has a filter on and a header checkbox, and someone asks:

  • When the user ticks "select all" on a filtered table, which rows are selected? The ones on screen, the ones the filter matches, or every row?
  • Which of them does my code get back?
  • What do I send to the server when "all" means a million rows?

This post answers each one on real data, with the React code. The demo loads the 1,067,371 invoice lines of the Online Retail II dataset into the free Community DataGrid. You can tick rows, filter by country, and see what the selection would look like as a request.

Selection belongs to rows, not positions

The first rule is the one that makes the others possible. A selection is a set of row keys, not row indexes:

import { DataGrid } from '@kanunilabs/datagrid-react';
<DataGrid
  dataSource={rows}
  rowKey="id"
  columns={columns}
  selection={{ mode: 'multiple', checkboxColumn: true }}
/>

Sort the table and the ticked rows move with their data. Filter them out of view and back, and they are still ticked. An index-based selection points at whichever row happens to be third after the sort, and that is a bug report waiting to happen.

What "select all" selects

With a filter on, the header checkbox selects the rows the filter matches. Not just the rows on screen, and not the rows the filter hides. Filter to France and tick the header. That selects 14,330 lines:

Untick three of them and you have 14,327. So far this is what everyone expects. The interesting part is what that selection is made of.

A million keys is a lot to hold and more to send

The obvious way to select all is to put every matching key in a set. For France that is 14,330 keys and nobody notices. Clear the filter and tick the header, and it is 1,067,371 keys, built on the main thread at the moment of the click. Then the user presses "Archive selected" and you send them. The demo has a button that does exactly that and times it in your browser. With three rows unticked from the whole table, the list is 1,067,368 ids and 7.1 MB of JSON. On our machine it took about a tenth of a second just to build, before the network. That is the eager form.

Deferred: a rule and its exceptions

Switch on deferred and select-all stops building a list. It records one flag, "everything the current filter matches", and every row the user unticks afterwards goes into a list of exceptions:

<DataGrid
  dataSource={rows}
  rowKey="id"
  columns={columns}
  selection={{ mode: 'multiple', checkboxColumn: true, deferred: true }}
  onReady={setGrid}
/>

Read it back with getSelectionDescriptor():

grid.getSelectionDescriptor();
// { allMatching: true, keys: [73, 75, 77] } ← keys are the EXCEPTIONS here

When allMatching is false, keys is the plain list of ticked rows, as before. The descriptor costs as much as the exceptions do, whether one row is selected or a million.

Send the rule, and the filter it was made under

A descriptor with allMatching: true means nothing without the filter, so send both:

const archive = async () => {
  const { allMatching, keys } = grid.getSelectionDescriptor();
  await fetch('/api/invoices/archive', {
    method: 'POST',
    body: JSON.stringify(
      allMatching
        ? {
            filter: grid.isFilterEnabled() ? grid.getEffectiveFilter() : null,
            search: grid.getSearchText(),
            except: keys,
          }
        : { ids: keys },
    ),
  });
};

The server turns it back into a query, something like:

UPDATE invoice_lines SET archived = true WHERE country = 'France' AND id NOT IN (73, 75, 77);

Three things are worth knowing before you build on this:

  • Your server has to apply the same filter. getEffectiveFilter() gives you the conditions the grid ran, and getSearchText() the quick search, which narrows the rows too. Your endpoint is responsible for running them identically. A filter the server reads differently (case, time zone, a column it does not know) selects different rows.
  • The rule is evaluated when the server runs it. A line added in France between the click and the request is included, because it matches. That is usually what "all matching" means, but if your users expect "exactly the rows I saw", send ids.
  • The rule follows the filter. Select all in France, then switch the filter to Germany, and the selection is now Germany's 17,624 rows: it was always "everything the current filter matches". The three French exceptions travel along, harmlessly, since no German row has those ids. If your screen should not allow that, clear the selection when the filter changes.

Which rows does my code get back?

This is the part of the Stack Overflow question that still trips people up, because two methods answer it differently:

  • getSelectedRows() returns the selected rows that are in the current view, in view order. A ticked row that the filter now hides is not in it.
  • getSelectedKeys() returns every selected key, visible or not. Under deferred selection the rule only covers the current view, so it walks the view to expand it into a list, which is the cost the mode exists to avoid.
  • getSelectedCount() is the number to show the user. With the eager form, the count includes ticked rows the filter now hides. So if you show "14,327 selected" and then act on getSelectedRows() after the user changed the filter, you are acting on fewer rows than you said. Decide which one you mean and use the same method for both.

All of it in one take: a filter, select all, three exceptions, a different filter, and the cost of turning it into a list:

Two bugs this post found

Preparing the screenshots for this post turned up two bugs in deferred select-all. Both are fixed in @kanunilabs/datagrid-react 1.3.0 (with @kanunilabs/datagrid-core 1.5.0), released on 5 October:

  • The selectionChanged event reported a select-all as no rows and a count of 0, and after the user unticked a row it reported that row as the selection. "Export selected rows only" read the selection the same way and wrote every row, the unticked ones included.
  • After a filter change, the count subtracted exceptions the new filter hides. The Germany step above showed 17,621 selected while every one of the 17,624 German rows was ticked: the three French exceptions were taken off a count they were never part of. On an older version, read the selection from getSelectionDescriptor(), which was right all along, not from the event.

What this costs

  • It is free. Selection, the checkbox column and deferred select-all are in the Community package.
  • Your server does the selecting. A deferred selection is a query, and your endpoint has to run it. If you cannot express your filters on the server, use the eager form and send ids.
  • Everything here is in the browser. The demo holds all 1,067,371 lines in the page; the server-side data docs cover the case where the server holds them and the grid only asks for a page at a time.

Try it

The demo is at /demos/selection, and /demos/selection?country=France opens it filtered. The selection docs cover single and multiple modes, row-click behaviour and the keyboard.

Data: Online Retail II, UCI Machine Learning Repository (Chen, 2019), CC BY 4.0.

Disclosure: this article was drafted with AI assistance. The demo and every behaviour described were checked against our packages and the published dataset in October 2026.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.