Skip to main content

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:

const { selected, change } = useSelected([] as Array<Item>);

return (
  <ul>
    {items.map((item) => (
      <li key={item.id}>
        <label>
          <input
            type="checkbox"
            id={item.id}
            checked={selected.includes(item)}
            onChange={(event) => change(event.target.checked, [item])}
          />
          {item.name}
        </label>
      </li>
    ))}
  </ul>
);

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:

export const useShiftSelected = <P>(
  initialState: Array<P>,
  change: (addOrRemove: boolean, items: Array<P>) => void
) => {
  const [previousSelected, setPreviousSelected] = useState<P | null>(null);
  const [previousChecked, setPreviousChecked] = useState<boolean>(false);
  const [currentSelected, setCurrentSelected] = useState<P | null>(null);

  const onChange = useCallback(
    (event: ChangeEvent<HTMLInputElement>, item: P) => {
      // @ts-ignore shiftKey is defined for click events
      if (event.nativeEvent.shiftKey) {
        const current = initialState.findIndex((x) => x === item);
        const previous = initialState.findIndex((x) => x === previousSelected);
        const previousCurrent = initialState.findIndex(
          (x) => x === currentSelected
        );
        const start = Math.min(current, previous);
        const end = Math.max(current, previous);
        if (start > -1 && end > -1) {
          change(previousChecked, initialState.slice(start, end + 1));
          if (previousCurrent > end) {
            change(
              !previousChecked,
              initialState.slice(end + 1, previousCurrent + 1)
            );
          }
          if (previousCurrent < start) {
            change(
              !previousChecked,
              initialState.slice(previousCurrent, start)
            );
          }
          setCurrentSelected(item);
          return;
        }
      } else {
        setPreviousSelected(item);
        setCurrentSelected(null);
        setPreviousChecked(event.target.checked);
      }
      change(event.target.checked, [item]);
    },
    [
      change,
      initialState,
      previousSelected,
      setPreviousSelected,
      previousChecked,
      setPreviousChecked,
      currentSelected,
      setCurrentSelected,
    ]
  );

  return onChange;
};

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:

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

Read more: React hook to persist state of a dialog in URL, Stop choosing DX over UX. Or maybe not?