ShadTable
DiscordGitHub

ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

Tree Table — Checkbox Selection

The same department/team/employee tree, with a checkbox column that cascades selection from a parent down to every descendant and reports indeterminate state for partially-selected branches.

npx shadcn add https://shadcn-table-library.vercel.app/r/tree-table-selection.json

0 of 11 rows selected

Name
Role
Status
Engineering
Department
Frontend
Team
Ava Thompson
Frontend LeadActive
Liam Chen
Frontend EngineerActive
Backend
Team
Sofia Patel
Backend LeadActive
Noah Garcia
Backend EngineerInactive
Design
Department
Product Design
Team
Mia Johnson
Design LeadActive
Ethan Kim
Product DesignerPending

How it works

1.Cascading selection is a built-in feature, not custom code

TanStack Table's row selection feature already understands getSubRows: checking a parent's checkbox selects every one of its descendants for free, no manual recursion needed. row.toggleSelected(true) on the Engineering row selects Frontend, Backend, and every employee beneath them in one call.

src/components/tree-select/columns.tsx
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected() ? true : row.getIsSomeSelected() ? 'indeterminate' : false}
onCheckedChange={(value) => row.toggleSelected(!!value)}
/>
)

2.Partial selection shows as indeterminate

row.getIsSomeSelected() reports true when some (but not all) of a row's descendants are selected — deselect just Liam Chen and the Frontend and Engineering checkboxes both drop from checked to indeterminate, without any manual bookkeeping of which branch is partially selected.

src/components/tree-select/columns.tsx
checked={
row.getIsSelected()
? true
: row.getIsSomeSelected()
? 'indeterminate'
: false
}

3.The header checkbox reflects the whole tree

table.getIsAllRowsSelected() and table.getIsSomeRowsSelected() aggregate across every row in the table, not just the visible/expanded ones, so the header checkbox is accurate even while some branches are collapsed. table.toggleAllRowsSelected(true) cascades a full select-all through every branch in one call.

src/components/tree-select/columns.tsx
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={(value) => table.toggleAllRowsSelected(!!value)}
/>
)

4.Selection and expansion are independent state

rowSelection and expanded are two separate pieces of controlled state. Collapsing Frontend doesn't clear Ava and Liam's selection — it just hides two already-selected rows. Re-expanding shows their checkboxes still checked, and the selected-count summary above the table never changes because it reads from the full row model, not just the currently visible rows.

src/components/tree-select/data-table.tsx
const selectedCount = table.getSelectedRowModel().flatRows.length
const totalCount = table.getRowModel().flatRows.length

5.getSelectedRowModel().rows is not a flat count for tree data

For nested rows, .rows only contains the top-level selected nodes with their selected descendants nested underneath (it preserves the hierarchy), so checking Engineering alone reports rows.length === 1 even though 7 rows are actually checked. .flatRows walks every selected row at every depth, which is what a "12 of 25 selected" style summary actually needs.

src/components/tree-select/data-table.tsx
// Selecting just "Engineering" (which cascades to 6 descendants):
table.getSelectedRowModel().rows.length // 1 — only the root match
table.getSelectedRowModel().flatRows.length // 7 — every selected row