DEV Community

137Foundry
137Foundry

Posted on

How to Build a Selection State Manager for Data Tables With Bulk Actions

If your bulk-select checkboxes live directly inside your table component's local state, you've probably already hit the bug where a user selects a row, changes a filter, and the selection count silently drifts from what's actually checked on screen. This guide walks through building a small, dedicated selection manager that survives pagination, filtering, and sorting, instead of patching the symptom every time a new edge case shows up.

rows of checkboxes in a data table interface
Photo by Towfiqu barbhuiya on Pexels

Step 1: Model Selection as a Set of IDs, Not Row Positions

The first mistake to avoid is tracking selection by row index or by array position. Once a table re-sorts or a filter changes what's visible, positions shift but the underlying records don't, and a selection keyed on position points at the wrong data almost immediately.

Instead, track selection as a set of stable record IDs, whatever unique identifier your data already has. A JavaScript Set is the natural structure here: membership checks, additions, and removals are all fast, and there's no ambiguity about what "selected" refers to regardless of how the table is currently displayed.

const selectedIds = new Set();
function toggleSelection(id) {
  if (selectedIds.has(id)) selectedIds.delete(id);
  else selectedIds.add(id);
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Separate "Select Page" From "Select All Matching Filter"

A single "select all" checkbox is ambiguous the moment your table has more rows than fit on one page. Build two distinct actions instead: one that selects only the rows currently rendered, and one that selects every record matching the active filter, even ones not currently loaded into the client.

The second action needs to be visually and behaviorally distinct, because it can resolve to a dramatically larger number than what's on screen. Show the resolved count explicitly, "Select all 4,213 records matching this filter," rather than letting users assume "select all" only means the twenty rows visible on the current page.

Step 3: Keep Selection State Independent of the Table's Render Cycle

If React or your framework of choice re-renders the table component on every filter or sort change, and your selection state lives inside that same component, you risk resetting selection unintentionally on re-render, or fighting the framework to preserve it. Lifting selection state into a separate store, a small context provider, a state management library, or a dedicated hook, keeps it stable independent of how often the table itself re-renders.

This separation also makes selection easier to test in isolation. A selection manager that exposes select(id), deselect(id), isSelected(id), and clear() as pure functions against a Set can be unit tested without rendering any UI at all.

Step 4: Handle Filter and Sort Changes Explicitly

When a filter changes, decide deliberately what happens to the existing selection rather than letting it happen by accident. Two reasonable choices exist: clear the selection entirely when the filter changes, which is safest but can frustrate users mid-workflow, or preserve selected IDs that still match the new filter while dropping ones that don't, with a visible notice telling the user their selection changed.

Sorting is simpler: since selection is keyed by ID rather than position, a sort change shouldn't affect selection at all as long as your selection manager was built correctly in step 1. If sorting does break your selection, that's usually a sign selection state leaked back into position-based logic somewhere.

Step 5: Surface the Selection Count Accessibly

The running count of selected items needs to be visible to sighted users and announced to assistive technology when it changes. Tables built without this in mind often render the count as plain text that updates visually but never triggers a screen reader announcement, which quietly excludes keyboard and screen-reader users from a feature aimed at people doing high-volume, repetitive work.

The W3C's Web Accessibility Initiative documents the ARIA patterns for accessible grid selection, including how to expose row selection state so assistive technology announces it consistently with what's shown visually. Building this in from the start is considerably easier than retrofitting it once the table component has grown complex.

Step 6: Build the Bulk Action Bar Off the Same Source of Truth

The toolbar that appears once one or more rows are selected, showing available bulk actions and the current count, should read directly from the same selection manager, not a separately maintained count. Duplicating the count in two places is exactly how the UI and the actual selection drift apart, which is the root cause of most of the "bulk action affected the wrong rows" bugs teams run into.

If you're building this on top of an existing table library, check whether it already has selection-state primitives you can hook into. TanStack Table ships row-selection state management as a built-in feature, which can save you from reimplementing steps 1 through 4 from scratch if it fits your stack.

Step 7: Test the Edge Cases That Actually Cause Incidents

Once the basic selection manager works, test the specific scenarios that cause production incidents: selecting rows, changing a filter, and confirming the selection count updates correctly; selecting "all matching filter," then narrowing the filter further, and confirming the count shrinks accordingly; and selecting rows across two different pages before triggering a bulk action, and confirming all of them, not just the ones on the currently visible page, get included.

These are the scenarios that don't show up in a quick manual click-through but do show up the first time a real user works through a large dataset across multiple pages, which is exactly when a bulk action is most valuable and most dangerous to get wrong. Writing these as automated tests against the selection manager directly, rather than relying on manual QA to catch them before every release, is worth the setup time given how easy the underlying bugs are to reintroduce during an unrelated refactor.

Handling Selection Across Grouped or Nested Rows

Tables with grouped rows, line items nested under a parent order, for instance, add a wrinkle worth planning for early. Selecting a parent conceptually selects its children, and selecting only some children needs an indeterminate visual state on the parent checkbox. The cleanest way to handle this is to keep the underlying selection set flat, every selectable row is just an ID in the same Set regardless of nesting level, and compute the parent's indeterminate state by checking whether some but not all of its children are present. Storing parent and child selection as separate flags instead of deriving one from the other is a reliable way to end up with the two silently drifting out of sync.

Where This Fits Into the Bigger Picture

A correct selection manager is the foundation everything else about bulk actions depends on. Confirmation dialogs that state an accurate count, undo that reverses exactly the right records, and audit logs that reflect what was actually selected, all fail quietly if the underlying selection state was wrong to begin with. 137Foundry's longer guide on designing bulk actions that don't destroy data covers the rest of that safety net, from confirmation copy to soft deletes to rate limiting the batch execution itself.

If your team is building out bulk actions across a growing product and wants a second set of eyes on the selection and safety architecture before it ships, that's the kind of scoping work 137Foundry's app development services regularly get pulled into, usually after the first version has already shipped and started drifting.

Top comments (0)