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.You'll write a hook that holds one collection and is asked, over and over, how it overlaps with another one it does not control.
You are building a table with a checkbox on every row and a select-all checkbox in the header. The header has three states: every row ticked, no row ticked, and the awkward one in the middle where some are.
That much is a morning's work. Here is the part that isn't. Your user ticks three invoices, then types into the filter box, and the table redraws with two rows in it. Nobody touched a checkbox. What is the header supposed to say now — and when they clear the filter, is the third invoice still ticked?
There are two collections here, and the hook only owns one of them.
items is handed in. It belongs to whoever is filtering, sorting, paginating and refetching, and it can be a different list on any render — without anything the hook did causing it. The selection is the hook's own, and nothing changes it except a user clicking something.
Every question this hook answers is a question about how those two overlap. isSelected(row) asks about one item. allSelected asks about all of them at once. And that is the thing to hold on to: the header checkbox is not a report on the selection. It is a claim about the rows on screen — these are all ticked — and only the rows on screen can settle it.
Which is why the one-liner everybody writes first cannot work. selected.length === items.length compares two numbers. Each number was counted from a different collection, and the moment you have a count you have thrown away which items you counted. Two counts being equal tells you nothing at all about the two collections holding the same things.
Read the top panel and the bug stops being about arithmetic. 3 === 2 is a perfectly correct evaluation of a question nobody asked.
The selection is a list of things, so: a list.
const { useState, useCallback } = require('react');
function useSelections(items, defaultSelected = []) {
const [selected, setSelected] = useState(defaultSelected);
const isSelected = (item) => selected.includes(item);
const select = useCallback((item) => {
setSelected((prev) => (prev.includes(item) ? prev : [...prev, item]));
}, []);
// ...and unSelect, toggle, selectAll, unSelectAll, all of them equally correct
const allSelected = selected.length === items.length;
const noneSelected = selected.length === 0;
const partiallySelected = !allSelected && !noneSelected;
return { selected, isSelected, select, allSelected, noneSelected, partiallySelected };
}
This is not a strawman. It passes sixteen of this question's twenty-three tests. Every verb works. Nothing is mutated. The helpers are stable. Tick boxes on a list that is standing still and the header says all, none and partial exactly when it should — because while items holds still, the count happens to track the truth.
It breaks the instant items moves, and it breaks in both directions. Filter five rows down to two while three are selected: the tally compares 3 to 2, finds them different, and reports not all selected over a table where every visible box is ticked. Now filter down to the two rows that aren't selected instead: 2 and 2 agree, and the header renders checked above two empty boxes. Same one-liner, opposite lie, and the second one is worse because it looks like it's working.
noneSelected has the same disease in a smaller way. selected.length === 0 asks whether the selection is empty. That is not the question either — the question is whether any of these rows is ticked, and a selection full of filtered-away items answers it wrongly.
And it doesn't stop at a wrong pixel. toggleAll asks allSelected which way to push. A header that wrongly believes it is unticked calls selectAll on rows that are already ticked, so the user clicks it and nothing happens at all.
const { useState, useCallback, useMemo } = require('react');
function useSelections(items, defaultSelected = []) {
// The Set is the state, not a cache of it. Every question this hook answers
// is a membership question asked once per row, so the collection that answers
// membership in one step is the one worth keeping.
const [selectedSet, setSelectedSet] = useState(() => new Set(defaultSelected));
// A reader, so it answers out of the render it was born in — no useCallback.
// This is the hot path: one call per row, on every render of the list.
const isSelected = (item) => selectedSet.has(item);
// Each writer is one pure updater reading `prev`, so calls in one event
// compose instead of all starting from the same render-time snapshot.
const select = useCallback((item) => {
// Handing back `prev` unchanged makes React bail out of the re-render.
setSelectedSet((prev) => (prev.has(item) ? prev : new Set(prev).add(item)));
}, []);
const unSelect = useCallback((item) => {
setSelectedSet((prev) => {
if (!prev.has(item)) return prev;
const next = new Set(prev);
next.delete(item);
return next;
});
}, []);
const toggle = useCallback((item) => {
setSelectedSet((prev) => {
const next = new Set(prev);
// `delete` reports whether it removed anything, so one lookup does both
// the question and half the answer.
if (!next.delete(item)) next.add(item);
return next;
});
}, []);
// These three read `items`, so they depend on `items`. They are wired to one
// header checkbox, not to a thousand rows, so that costs nothing.
const selectAll = useCallback(() => {
setSelectedSet((prev) => {
const next = new Set(prev);
items.forEach((item) => next.add(item));
// Adding can only grow a Set, so an unchanged size means nothing was new.
return next.size === prev.size ? prev : next;
});
}, [items]);
// Only the rows on screen. A tick made under a different filter is not this
// button's business — that is what setSelected([]) is for.
const unSelectAll = useCallback(() => {
setSelectedSet((prev) => {
const next = new Set(prev);
items.forEach((item) => next.delete(item));
return next.size === prev.size ? prev : next;
});
}, [items]);
const setSelected = useCallback((next) => setSelectedSet(new Set(next)), []);
// The whole question. Ask the ITEMS, one membership lookup each — never the
// two tallies, which can agree for the wrong reasons and disagree for none.
const noneSelected = items.every((item) => !selectedSet.has(item));
// `[].every(...)` is true, so an empty list is vacuously "all selected". It
// is also vacuously "none selected". A checkbox is a claim about rows; with
// no rows there is nothing to claim, so the length guard breaks the tie.
const allSelected = items.length > 0 && items.every((item) => selectedSet.has(item));
const partiallySelected = !noneSelected && !allSelected;
const toggleAll = useCallback(() => {
if (allSelected) unSelectAll();
else selectAll();
}, [allSelected, selectAll, unSelectAll]);
// One array per selection change, rather than one per render — so a caller
// can put `selected` in a dependency array without it firing forever.
const selected = useMemo(() => Array.from(selectedSet), [selectedSet]);
return {
selected,
isSelected,
select,
unSelect,
toggle,
selectAll,
unSelectAll,
toggleAll,
allSelected,
noneSelected,
partiallySelected,
setSelected,
};
}
module.exports = { useSelections };
Two things changed. The state became a Set, so membership is one lookup rather than a walk. And the three booleans stopped counting and started asking — items.every(...), once per item, against that Set.
Everything else follows from those. selected is now the derived one: Array.from(selectedSet), memoized so it only changes when the selection does. Note which way round that is. The array is the view; the Set is the truth. It has to be that way round, because selected cannot be derived from items — the selection is allowed to hold things that are not in the list, and that is not an edge case, it is what a filtered row is.
Here is the part worth slowing down for, because the two halves of this question are the same half.
The honest tri-state has to ask every item on screen. That is n membership questions per render — on top of the n that isSelected already costs, once per row. So fixing the lie the obvious way makes the naive version slower, not faster: you go from one free .length comparison to a thousand array scans. items.every((i) => selected.includes(i)) is correct and quadratic.
That is what the Set buys, and it isn't speed for its own sake. It is what makes the correct answer cheap enough that you'll actually ship it.
Measured in this repo on React 19.2.6, one render pass of a table where everything is selected:
| rows | array scan | Set lookup | slower by |
|---|---|---|---|
| 100 | 0.019 ms | 0.001 ms | 17x |
| 1000 | 1.802 ms | 0.019 ms | 96x |
| 5000 | 40.15 ms | 0.102 ms | 395x |
Ten times the rows, a hundred times the work — that shape is the whole story. At 1000 rows it is 1,001,001 comparisons against 2,001 lookups, and that is per render: every keystroke in the filter box, every hover that flips a row's class. For scale, React's own work to re-render a 1000-row list is about 3.3 ms, so the array version adds roughly half a render's cost again to answer a question the Set answers in 0.6% of one. At 5000 rows it is 40 ms of pure membership arithmetic before React does anything at all, and one frame at 60fps is 16.7 ms.
And now the honest part, which is why this ships. Look at the 100-row line: 0.019 ms. That is nothing. It will never be anything. You develop against a fixture of twenty rows, the table is instant, it passes review, and it lands. The quadratic doesn't announce itself — it waits for the customer with 5000 invoices, and then it arrives as typing in the filter box feels laggy, which is nobody's idea of a selection bug. Reach for the Set when you write the hook, not when the ticket comes in.
A Set holds references. new Set([{ id: 1 }]).has({ id: 1 }) is false, and so is [{ id: 1 }].includes({ id: 1 }) — this is not a Set quirk, it is what === does with objects, and the array version has it too.
Most of the time this is exactly what you want and you never notice. Then your table starts polling.
Read the bottom panel carefully, because it is worse than the ticks disappeared. selected still has one item in it. The user sees an untouched table with nothing selected; the Delete selected button still thinks it has an invoice to delete, and it is holding an object that no longer corresponds to any row on screen. Nothing threw. Nothing warned.
So the rule this hook asks of you: keep item identities stable across renders, or hand it items that are already primitives. In practice that means selecting row.id rather than row — and the reason useSelections(rows.map((r) => r.id)) is such common advice is that a number is a number no matter how many times you fetch it. If you must select whole objects, memoize the array that holds them so a re-render doesn't mint new ones, and see Going further for the version that takes a key extractor.
An invoice table: useSelections(invoices, []), where the parent computes invoices by filtering a full list of five — INV-1 through INV-5.
new Set([]). noneSelected asks all five items: five misses, so true. allSelected is 5 > 0 && ..., and the first lookup misses, so false. partiallySelected is !true && !false — false. The header renders unticked. Nothing about that needed a special case for the empty selection.toggle calls, three renders. Each one copies the Set, finds delete returned false, and adds instead. The Set is now {INV-1, INV-2, INV-3}.noneSelected asks INV-1 first — a hit, so !true is false, and every stops there. allSelected walks to INV-4, misses, and stops. partiallySelected is !false && !false — true. The header renders indeterminate.items = ['INV-1', 'INV-2']. Nobody clicked anything, so the Set is untouched and no state update happened — the hook body simply runs again with a different items. noneSelected: INV-1 hits, so false. allSelected: 2 > 0, INV-1 hits, INV-2 hits — true. The header ticks itself. Both rows on screen are ticked, so that is the truth. The first attempt got here and compared 3 to 2.toggleAll reads allSelected, which is true, so it calls unSelectAll. That deletes only INV-1 and INV-2 — the items on screen — leaving {INV-3}. Both visible rows untick.items is all five again. selected is ['INV-3']: it was never on screen for step 5, so nothing in step 5 was about it. The header goes back to indeterminate, and INV-3 is still ticked, exactly where the user left it.Step 6 is the payoff for the decision in step 5, and step 4 is the payoff for asking rather than counting.
Of the four big hook collections, only ahooks ships this one. react-use has a useSet and a useList; @react-hookz/web has useSet, useList and useMap; usehooks-ts has none of the three. So there is exactly one prior implementation to compare against — and on the things this question is about, it agrees, which is worth saying plainly rather than hunting for a fight.
Verified against ahooks 3.9.7 from npm, running under React 19.2.6:
Map from the selection, memoized on [selected], and every isSelected is one .has. Same conclusion as here, one structure over — and the Map is the better call for their design, because their key extractor means they need key -> item to rebuild the array.items.every((item) => selectedMap.has(getKey(item))), not a length comparison. Same conclusion.items; a filtered-away item stays selected. Same conclusion — I filtered a five-row list to two with three selected and selected still read ['a', 'b', 'c'] while allSelected correctly reported true.items.every(has) && !noneSelected. Since allSelected and noneSelected can only both be true when there are no items at all, that trailing clause is exactly and only the empty-list guard. items.length > 0 above says the same thing more directly.Where it goes further. useSelections(items, { defaultSelected, itemKey }) takes a key extractor, so the identity trap above is a solved problem rather than a warning. That is a real feature this hook does not have, and the Going further section owes it a nod.
Where it is worth pushing back. It mutates the memoized Map and then reads the new state out of the mutation:
// ahooks/src/useSelections/index.ts, trimmed
const selectedMap = useMemo(() => {
const keyToItemMap = new Map();
selected.forEach((item) => keyToItemMap.set(getKey(item), item));
return keyToItemMap;
}, [selected]);
const select = (item) => {
selectedMap.set(getKey(item), item); // mutate the memo...
setSelected(Array.from(selectedMap.values())); // ...then read the state out of it
};
React's docs are explicit that you should not mutate the value useMemo hands back, and that React may throw the cache away. And yet it works — because the mutation is doing the job a functional updater does here: select(a); select(b) in one event both hit the same live Map, so the second sees the first. Take the mutation out without putting an updater in and batched selects would drop each other. It is load-bearing, which is the uncomfortable kind of impure.
Two smaller things fall out of the same design. setSelected(Array.from(...)) builds a fresh array every call, so a redundant select of an already-selected item still re-renders — measured, one wasted render each time, where returning prev bails out. And the useMemo is keyed on [selected] while its body reads getKey, so changing itemKey without changing the selection leaves the map keyed the old way. That last one is useValidatedState's lesson wearing a different hat, and it is the reason the three booleans here are just expressions with no memo around them.
selected.length === items.length. The bug this question is made of. It compares two counts taken from two different collections, so it goes wrong the moment they stop being the same collection — reporting not all over a fully ticked filtered table, and all over an empty one whose count happens to match. Fix: items.every((i) => isSelected(i)). Ask the rows; they are what the header is describing.noneSelected = selected.length === 0. Looks unimpeachable and isn't. It asks whether the selection is empty, but the header wants to know whether any of these rows is ticked. Filter to rows that aren't selected and it reports something ticked when nothing on screen is. Fix: items.every((i) => !isSelected(i)) — and notice this makes noneSelected true while selected is non-empty, which is correct, not a bug.[].every(fn) is true for every fn, so an empty table is vacuously all-selected and vacuously none-selected. Ship that and the header renders a tick above zero rows. Fix: items.length > 0 && in front of allSelected, and let noneSelected keep the vacuous true — a table with no rows has no ticked rows.items.every((i) => selected.includes(i)) is correct and quadratic — it is the fix that makes an n-row table do n² work per render. The Set isn't there to make a working thing faster; it is there so the correct version is affordable. Fix: make membership O(1) first, then ask freely.items changes. Tempting, because it would make the tally honest again. It also silently unticks a row every time the user types a letter in the filter box, and nothing brings it back when they delete the letter. Fix: leave the selection alone; it is the hook's own, and items is somebody else's.selected quietly keeps the old ones — a selection nobody can see and Delete selected can still act on. Fix: select ids, keep the item array stable, or take a key extractor.unSelectAll as setSelected([]). They are different verbs. Unticking the header should untick the rows under it, not silently discard forty selections the user made under a different filter. Fix: unSelectAll removes the current items; hand callers setSelected([]) for the other thing and let them choose.useSelections(items, defaultSelected, itemKey) closes the identity trap: hold a Map of itemKey(item) -> item instead of a Set, and every lookup becomes map.has(itemKey(item)). With itemKey = (row) => row.id a refetch keeps the selection, because 1 is 1 whatever object it arrived in. The Map earns its place the moment you need the original items back out of selected — a key alone cannot rebuild them. ahooks ships exactly this, and the cost is a second parameter plus a rule that the extractor must be stable.partiallySelected harder to spend than it looks. There is no indeterminate content attribute: el.setAttribute('indeterminate', 'true') leaves el.indeterminate sitting at false, and React 19 drops an indeterminate JSX prop entirely with a warning that it received a non-boolean attribute. The route that works is a ref plus an effect — ref.current.indeterminate = partiallySelected — which is one of the few places a modern React component genuinely has to reach for the DOM.clearAll. ahooks ships it alongside unSelectAll precisely because they are different verbs — one drops the visible rows, the other empties the selection outright. Here it is setSelected([]), which is why it isn't a member; add it the moment you catch yourself writing that at three call sites.items can express, because those conversations were never in items. The usual answer is to stop storing a selection and start storing a predicate plus an exception list — which is a different hook, and a much harder one.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
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.You'll write a hook that holds one collection and is asked, over and over, how it overlaps with another one it does not control.
You are building a table with a checkbox on every row and a select-all checkbox in the header. The header has three states: every row ticked, no row ticked, and the awkward one in the middle where some are.
That much is a morning's work. Here is the part that isn't. Your user ticks three invoices, then types into the filter box, and the table redraws with two rows in it. Nobody touched a checkbox. What is the header supposed to say now — and when they clear the filter, is the third invoice still ticked?
There are two collections here, and the hook only owns one of them.
items is handed in. It belongs to whoever is filtering, sorting, paginating and refetching, and it can be a different list on any render — without anything the hook did causing it. The selection is the hook's own, and nothing changes it except a user clicking something.
Every question this hook answers is a question about how those two overlap. isSelected(row) asks about one item. allSelected asks about all of them at once. And that is the thing to hold on to: the header checkbox is not a report on the selection. It is a claim about the rows on screen — these are all ticked — and only the rows on screen can settle it.
Which is why the one-liner everybody writes first cannot work. selected.length === items.length compares two numbers. Each number was counted from a different collection, and the moment you have a count you have thrown away which items you counted. Two counts being equal tells you nothing at all about the two collections holding the same things.
Read the top panel and the bug stops being about arithmetic. 3 === 2 is a perfectly correct evaluation of a question nobody asked.
The selection is a list of things, so: a list.
const { useState, useCallback } = require('react');
function useSelections(items, defaultSelected = []) {
const [selected, setSelected] = useState(defaultSelected);
const isSelected = (item) => selected.includes(item);
const select = useCallback((item) => {
setSelected((prev) => (prev.includes(item) ? prev : [...prev, item]));
}, []);
// ...and unSelect, toggle, selectAll, unSelectAll, all of them equally correct
const allSelected = selected.length === items.length;
const noneSelected = selected.length === 0;
const partiallySelected = !allSelected && !noneSelected;
return { selected, isSelected, select, allSelected, noneSelected, partiallySelected };
}
This is not a strawman. It passes sixteen of this question's twenty-three tests. Every verb works. Nothing is mutated. The helpers are stable. Tick boxes on a list that is standing still and the header says all, none and partial exactly when it should — because while items holds still, the count happens to track the truth.
It breaks the instant items moves, and it breaks in both directions. Filter five rows down to two while three are selected: the tally compares 3 to 2, finds them different, and reports not all selected over a table where every visible box is ticked. Now filter down to the two rows that aren't selected instead: 2 and 2 agree, and the header renders checked above two empty boxes. Same one-liner, opposite lie, and the second one is worse because it looks like it's working.
noneSelected has the same disease in a smaller way. selected.length === 0 asks whether the selection is empty. That is not the question either — the question is whether any of these rows is ticked, and a selection full of filtered-away items answers it wrongly.
And it doesn't stop at a wrong pixel. toggleAll asks allSelected which way to push. A header that wrongly believes it is unticked calls selectAll on rows that are already ticked, so the user clicks it and nothing happens at all.
const { useState, useCallback, useMemo } = require('react');
function useSelections(items, defaultSelected = []) {
// The Set is the state, not a cache of it. Every question this hook answers
// is a membership question asked once per row, so the collection that answers
// membership in one step is the one worth keeping.
const [selectedSet, setSelectedSet] = useState(() => new Set(defaultSelected));
// A reader, so it answers out of the render it was born in — no useCallback.
// This is the hot path: one call per row, on every render of the list.
const isSelected = (item) => selectedSet.has(item);
// Each writer is one pure updater reading `prev`, so calls in one event
// compose instead of all starting from the same render-time snapshot.
const select = useCallback((item) => {
// Handing back `prev` unchanged makes React bail out of the re-render.
setSelectedSet((prev) => (prev.has(item) ? prev : new Set(prev).add(item)));
}, []);
const unSelect = useCallback((item) => {
setSelectedSet((prev) => {
if (!prev.has(item)) return prev;
const next = new Set(prev);
next.delete(item);
return next;
});
}, []);
const toggle = useCallback((item) => {
setSelectedSet((prev) => {
const next = new Set(prev);
// `delete` reports whether it removed anything, so one lookup does both
// the question and half the answer.
if (!next.delete(item)) next.add(item);
return next;
});
}, []);
// These three read `items`, so they depend on `items`. They are wired to one
// header checkbox, not to a thousand rows, so that costs nothing.
const selectAll = useCallback(() => {
setSelectedSet((prev) => {
const next = new Set(prev);
items.forEach((item) => next.add(item));
// Adding can only grow a Set, so an unchanged size means nothing was new.
return next.size === prev.size ? prev : next;
});
}, [items]);
// Only the rows on screen. A tick made under a different filter is not this
// button's business — that is what setSelected([]) is for.
const unSelectAll = useCallback(() => {
setSelectedSet((prev) => {
const next = new Set(prev);
items.forEach((item) => next.delete(item));
return next.size === prev.size ? prev : next;
});
}, [items]);
const setSelected = useCallback((next) => setSelectedSet(new Set(next)), []);
// The whole question. Ask the ITEMS, one membership lookup each — never the
// two tallies, which can agree for the wrong reasons and disagree for none.
const noneSelected = items.every((item) => !selectedSet.has(item));
// `[].every(...)` is true, so an empty list is vacuously "all selected". It
// is also vacuously "none selected". A checkbox is a claim about rows; with
// no rows there is nothing to claim, so the length guard breaks the tie.
const allSelected = items.length > 0 && items.every((item) => selectedSet.has(item));
const partiallySelected = !noneSelected && !allSelected;
const toggleAll = useCallback(() => {
if (allSelected) unSelectAll();
else selectAll();
}, [allSelected, selectAll, unSelectAll]);
// One array per selection change, rather than one per render — so a caller
// can put `selected` in a dependency array without it firing forever.
const selected = useMemo(() => Array.from(selectedSet), [selectedSet]);
return {
selected,
isSelected,
select,
unSelect,
toggle,
selectAll,
unSelectAll,
toggleAll,
allSelected,
noneSelected,
partiallySelected,
setSelected,
};
}
module.exports = { useSelections };
Two things changed. The state became a Set, so membership is one lookup rather than a walk. And the three booleans stopped counting and started asking — items.every(...), once per item, against that Set.
Everything else follows from those. selected is now the derived one: Array.from(selectedSet), memoized so it only changes when the selection does. Note which way round that is. The array is the view; the Set is the truth. It has to be that way round, because selected cannot be derived from items — the selection is allowed to hold things that are not in the list, and that is not an edge case, it is what a filtered row is.
Here is the part worth slowing down for, because the two halves of this question are the same half.
The honest tri-state has to ask every item on screen. That is n membership questions per render — on top of the n that isSelected already costs, once per row. So fixing the lie the obvious way makes the naive version slower, not faster: you go from one free .length comparison to a thousand array scans. items.every((i) => selected.includes(i)) is correct and quadratic.
That is what the Set buys, and it isn't speed for its own sake. It is what makes the correct answer cheap enough that you'll actually ship it.
Measured in this repo on React 19.2.6, one render pass of a table where everything is selected:
| rows | array scan | Set lookup | slower by |
|---|---|---|---|
| 100 | 0.019 ms | 0.001 ms | 17x |
| 1000 | 1.802 ms | 0.019 ms | 96x |
| 5000 | 40.15 ms | 0.102 ms | 395x |
Ten times the rows, a hundred times the work — that shape is the whole story. At 1000 rows it is 1,001,001 comparisons against 2,001 lookups, and that is per render: every keystroke in the filter box, every hover that flips a row's class. For scale, React's own work to re-render a 1000-row list is about 3.3 ms, so the array version adds roughly half a render's cost again to answer a question the Set answers in 0.6% of one. At 5000 rows it is 40 ms of pure membership arithmetic before React does anything at all, and one frame at 60fps is 16.7 ms.
And now the honest part, which is why this ships. Look at the 100-row line: 0.019 ms. That is nothing. It will never be anything. You develop against a fixture of twenty rows, the table is instant, it passes review, and it lands. The quadratic doesn't announce itself — it waits for the customer with 5000 invoices, and then it arrives as typing in the filter box feels laggy, which is nobody's idea of a selection bug. Reach for the Set when you write the hook, not when the ticket comes in.
A Set holds references. new Set([{ id: 1 }]).has({ id: 1 }) is false, and so is [{ id: 1 }].includes({ id: 1 }) — this is not a Set quirk, it is what === does with objects, and the array version has it too.
Most of the time this is exactly what you want and you never notice. Then your table starts polling.
Read the bottom panel carefully, because it is worse than the ticks disappeared. selected still has one item in it. The user sees an untouched table with nothing selected; the Delete selected button still thinks it has an invoice to delete, and it is holding an object that no longer corresponds to any row on screen. Nothing threw. Nothing warned.
So the rule this hook asks of you: keep item identities stable across renders, or hand it items that are already primitives. In practice that means selecting row.id rather than row — and the reason useSelections(rows.map((r) => r.id)) is such common advice is that a number is a number no matter how many times you fetch it. If you must select whole objects, memoize the array that holds them so a re-render doesn't mint new ones, and see Going further for the version that takes a key extractor.
An invoice table: useSelections(invoices, []), where the parent computes invoices by filtering a full list of five — INV-1 through INV-5.
new Set([]). noneSelected asks all five items: five misses, so true. allSelected is 5 > 0 && ..., and the first lookup misses, so false. partiallySelected is !true && !false — false. The header renders unticked. Nothing about that needed a special case for the empty selection.toggle calls, three renders. Each one copies the Set, finds delete returned false, and adds instead. The Set is now {INV-1, INV-2, INV-3}.noneSelected asks INV-1 first — a hit, so !true is false, and every stops there. allSelected walks to INV-4, misses, and stops. partiallySelected is !false && !false — true. The header renders indeterminate.items = ['INV-1', 'INV-2']. Nobody clicked anything, so the Set is untouched and no state update happened — the hook body simply runs again with a different items. noneSelected: INV-1 hits, so false. allSelected: 2 > 0, INV-1 hits, INV-2 hits — true. The header ticks itself. Both rows on screen are ticked, so that is the truth. The first attempt got here and compared 3 to 2.toggleAll reads allSelected, which is true, so it calls unSelectAll. That deletes only INV-1 and INV-2 — the items on screen — leaving {INV-3}. Both visible rows untick.items is all five again. selected is ['INV-3']: it was never on screen for step 5, so nothing in step 5 was about it. The header goes back to indeterminate, and INV-3 is still ticked, exactly where the user left it.Step 6 is the payoff for the decision in step 5, and step 4 is the payoff for asking rather than counting.
Of the four big hook collections, only ahooks ships this one. react-use has a useSet and a useList; @react-hookz/web has useSet, useList and useMap; usehooks-ts has none of the three. So there is exactly one prior implementation to compare against — and on the things this question is about, it agrees, which is worth saying plainly rather than hunting for a fight.
Verified against ahooks 3.9.7 from npm, running under React 19.2.6:
Map from the selection, memoized on [selected], and every isSelected is one .has. Same conclusion as here, one structure over — and the Map is the better call for their design, because their key extractor means they need key -> item to rebuild the array.items.every((item) => selectedMap.has(getKey(item))), not a length comparison. Same conclusion.items; a filtered-away item stays selected. Same conclusion — I filtered a five-row list to two with three selected and selected still read ['a', 'b', 'c'] while allSelected correctly reported true.items.every(has) && !noneSelected. Since allSelected and noneSelected can only both be true when there are no items at all, that trailing clause is exactly and only the empty-list guard. items.length > 0 above says the same thing more directly.Where it goes further. useSelections(items, { defaultSelected, itemKey }) takes a key extractor, so the identity trap above is a solved problem rather than a warning. That is a real feature this hook does not have, and the Going further section owes it a nod.
Where it is worth pushing back. It mutates the memoized Map and then reads the new state out of the mutation:
// ahooks/src/useSelections/index.ts, trimmed
const selectedMap = useMemo(() => {
const keyToItemMap = new Map();
selected.forEach((item) => keyToItemMap.set(getKey(item), item));
return keyToItemMap;
}, [selected]);
const select = (item) => {
selectedMap.set(getKey(item), item); // mutate the memo...
setSelected(Array.from(selectedMap.values())); // ...then read the state out of it
};
React's docs are explicit that you should not mutate the value useMemo hands back, and that React may throw the cache away. And yet it works — because the mutation is doing the job a functional updater does here: select(a); select(b) in one event both hit the same live Map, so the second sees the first. Take the mutation out without putting an updater in and batched selects would drop each other. It is load-bearing, which is the uncomfortable kind of impure.
Two smaller things fall out of the same design. setSelected(Array.from(...)) builds a fresh array every call, so a redundant select of an already-selected item still re-renders — measured, one wasted render each time, where returning prev bails out. And the useMemo is keyed on [selected] while its body reads getKey, so changing itemKey without changing the selection leaves the map keyed the old way. That last one is useValidatedState's lesson wearing a different hat, and it is the reason the three booleans here are just expressions with no memo around them.
selected.length === items.length. The bug this question is made of. It compares two counts taken from two different collections, so it goes wrong the moment they stop being the same collection — reporting not all over a fully ticked filtered table, and all over an empty one whose count happens to match. Fix: items.every((i) => isSelected(i)). Ask the rows; they are what the header is describing.noneSelected = selected.length === 0. Looks unimpeachable and isn't. It asks whether the selection is empty, but the header wants to know whether any of these rows is ticked. Filter to rows that aren't selected and it reports something ticked when nothing on screen is. Fix: items.every((i) => !isSelected(i)) — and notice this makes noneSelected true while selected is non-empty, which is correct, not a bug.[].every(fn) is true for every fn, so an empty table is vacuously all-selected and vacuously none-selected. Ship that and the header renders a tick above zero rows. Fix: items.length > 0 && in front of allSelected, and let noneSelected keep the vacuous true — a table with no rows has no ticked rows.items.every((i) => selected.includes(i)) is correct and quadratic — it is the fix that makes an n-row table do n² work per render. The Set isn't there to make a working thing faster; it is there so the correct version is affordable. Fix: make membership O(1) first, then ask freely.items changes. Tempting, because it would make the tally honest again. It also silently unticks a row every time the user types a letter in the filter box, and nothing brings it back when they delete the letter. Fix: leave the selection alone; it is the hook's own, and items is somebody else's.selected quietly keeps the old ones — a selection nobody can see and Delete selected can still act on. Fix: select ids, keep the item array stable, or take a key extractor.unSelectAll as setSelected([]). They are different verbs. Unticking the header should untick the rows under it, not silently discard forty selections the user made under a different filter. Fix: unSelectAll removes the current items; hand callers setSelected([]) for the other thing and let them choose.useSelections(items, defaultSelected, itemKey) closes the identity trap: hold a Map of itemKey(item) -> item instead of a Set, and every lookup becomes map.has(itemKey(item)). With itemKey = (row) => row.id a refetch keeps the selection, because 1 is 1 whatever object it arrived in. The Map earns its place the moment you need the original items back out of selected — a key alone cannot rebuild them. ahooks ships exactly this, and the cost is a second parameter plus a rule that the extractor must be stable.partiallySelected harder to spend than it looks. There is no indeterminate content attribute: el.setAttribute('indeterminate', 'true') leaves el.indeterminate sitting at false, and React 19 drops an indeterminate JSX prop entirely with a warning that it received a non-boolean attribute. The route that works is a ref plus an effect — ref.current.indeterminate = partiallySelected — which is one of the few places a modern React component genuinely has to reach for the DOM.clearAll. ahooks ships it alongside unSelectAll precisely because they are different verbs — one drops the visible rows, the other empties the selection outright. Here it is setSelected([]), which is why it isn't a member; add it the moment you catch yourself writing that at three call sites.items can express, because those conversations were never in items. The usual answer is to stop storing a selection and start storing a predicate plus an exception list — which is a different hook, and a much harder one.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.