useSelectionsLoading saved progress…

useSelections

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.

Signature

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;
};

Examples

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']

Notes

  • The tri-state is about 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.
  • An empty list is not a selected list. Decide what the header checkbox shows over a table with no rows, and pin it. Watch out: [].every(fn) is true for any fn.
  • The selection outlives the list. Filtering a row away does not untick it. 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.
  • Items are compared the way === 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.
  • A list may hold the same value twice, and a value that is not in the list may still be selected. Neither is an error.
Loading editor…