useSelections(items, defaultSelected) holds a multi-select over a list and answers, at any moment, whether the items currently on screen are all selected, none selected, or somewhere in between. It gives you back the selection, an isSelected(item) for each row, verbs for changing one item or all of them, and the three booleans a select-all header checkbox needs.
The interesting part is not ticking boxes. It is that the selection and the list are two independent things. A user ticks three rows, types into the filter box, and now the table shows two rows — both of them ticked, with a third selection sitting off screen. The header has to describe what the user can see, and the selection has to remember what they chose.
function useSelections<T>(items: T[], defaultSelected?: T[]): {
selected: T[]; // in selection order, not list order
isSelected: (item: T) => boolean; // called once per row, every render
select: (item: T) => void;
unSelect: (item: T) => void;
toggle: (item: T) => void;
selectAll: () => void; // over the CURRENT items
unSelectAll: () => void; // over the CURRENT items
toggleAll: () => void;
allSelected: boolean; // the header checkbox's three states,
noneSelected: boolean; // each one a claim about `items`
partiallySelected: boolean;
setSelected: (next: Iterable<T>) => void;
};
The three booleans are what the header row is made of:
function Table({ rows }) {
const { isSelected, toggle, toggleAll, allSelected, partiallySelected, selected } =
useSelections(rows, []);
return (
<table>
<thead>
<tr>
<th>
<input type="checkbox" checked={allSelected} onChange={toggleAll} />
</th>
<th>Delete ({selected.length})</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row}>
<td>
<input type="checkbox" checked={isSelected(row)} onChange={() => toggle(row)} />
</td>
<td>{row}</td>
</tr>
))}
</tbody>
</table>
);
}
The header answers for items, and items is allowed to change underneath it:
// items: ['a', 'b', 'c', 'd', 'e']
select('a');
select('b');
select('c');
allSelected; // false — five rows, three ticks
partiallySelected; // true
// the user filters the table down to ['a', 'b'] — nothing was ticked or unticked
allSelected; // true — both rows on screen are ticked
selected; // ['a', 'b', 'c'] — c is still selected, it is just not here
unSelectAll(); // unticks the rows on screen
selected; // ['c']
items, not about selected. allSelected means every item on screen is selected. Comparing the two lengths answers a different question, and the two answers part company the moment the list changes.[].every(fn) is true for any fn.selectAll and unSelectAll act on the items on screen and leave the rest of the selection alone.isSelected is the hot path. It is called once per row on every render — a filtered table calls it on every keystroke. Membership should not get slower as the selection grows.=== compares them. Two objects with the same fields are two different items. Keep object identities stable across renders, or key the list on something you own.