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.json0 of 11 rows selected
Name | Role | Status | |
|---|---|---|---|
Engineering | Department | — | |
Frontend | Team | — | |
Ava Thompson | Frontend Lead | Active | |
Liam Chen | Frontend Engineer | Active | |
Backend | Team | — | |
Sofia Patel | Backend Lead | Active | |
Noah Garcia | Backend Engineer | Inactive | |
Design | Department | — | |
Product Design | Team | — | |
Mia Johnson | Design Lead | Active | |
Ethan Kim | Product Designer | Pending |
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.
cell: ({ row }) => (<Checkboxchecked={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.
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.
header: ({ table }) => (<Checkboxchecked={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.
const selectedCount = table.getSelectedRowModel().flatRows.lengthconst 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.
// Selecting just "Engineering" (which cascades to 6 descendants):table.getSelectedRowModel().rows.length // 1 — only the root matchtable.getSelectedRowModel().flatRows.length // 7 — every selected row