React hook to select multiple items with a shift

Imagine you have a list (or a table) with checkboxes, for example, to do list, mail inbox, or “back-office” page. Checkboxes are used to select items for batch actions, for example, delete or archive, etc.

Code can look like this:

 1const { selected, change } = useSelected([] as Array<Item>);
 2
 3return (
 4  <ul>
 5    {items.map((item) => (
 6      <li key={item.id}>
 7        <label>
 8          <input
 9            type="checkbox"
10            id={item.id}
11            checked={selected.includes(item)}
12            onChange={(event) => change(event.target.checked, [item])}
13          />
14          {item.name}
15        </label>
16      </li>
17    ))}
18  </ul>
19);

Selecting with shift

It is a nice UX trick to allow to select multiple items with shift:

  • user clicks on checkbox “A”
  • user clicks on checkbox “Z” while holding down Shift key
  • all checkboxes between “A” and “Z” get checked

Small thing, but can save a lot of time if user needs to select a lot of items. For example, you can test this feature in Gmail.

The hook

Here is a hook I implemented to support this:

 1export const useShiftSelected = <P>(
 2  initialState: Array<P>,
 3  change: (addOrRemove: boolean, items: Array<P>) => void
 4) => {
 5  const [previousSelected, setPreviousSelected] = useState<P | null>(null);
 6  const [previousChecked, setPreviousChecked] = useState<boolean>(false);
 7  const [currentSelected, setCurrentSelected] = useState<P | null>(null);
 8
 9  const onChange = useCallback(
10    (event: ChangeEvent<HTMLInputElement>, item: P) => {
11      // @ts-ignore shiftKey is defined for click events
12      if (event.nativeEvent.shiftKey) {
13        const current = initialState.findIndex((x) => x === item);
14        const previous = initialState.findIndex((x) => x === previousSelected);
15        const previousCurrent = initialState.findIndex(
16          (x) => x === currentSelected
17        );
18        const start = Math.min(current, previous);
19        const end = Math.max(current, previous);
20        if (start > -1 && end > -1) {
21          change(previousChecked, initialState.slice(start, end + 1));
22          if (previousCurrent > end) {
23            change(
24              !previousChecked,
25              initialState.slice(end + 1, previousCurrent + 1)
26            );
27          }
28          if (previousCurrent < start) {
29            change(
30              !previousChecked,
31              initialState.slice(previousCurrent, start)
32            );
33          }
34          setCurrentSelected(item);
35          return;
36        }
37      } else {
38        setPreviousSelected(item);
39        setCurrentSelected(null);
40        setPreviousChecked(event.target.checked);
41      }
42      change(event.target.checked, [item]);
43    },
44    [
45      change,
46      initialState,
47      previousSelected,
48      setPreviousSelected,
49      previousChecked,
50      setPreviousChecked,
51      currentSelected,
52      setCurrentSelected,
53    ]
54  );
55
56  return onChange;
57};

Not necessary the best code, but demonstrates the idea.

Source code:

Code with a hook

What I like about it, is that I don’t need to change a lot of original code:

1const { selected, change } = useSelected([] as Array<Item>);
2+ const onChange = useShiftSelected(items, change)
3...
4- onChange={(event) => change(event.target.checked, [item])}
5+ onChange={(event) => onChange(event, item)}

Except where otherwise noted, content on this site is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0