Multi-Select Checkboxes with React Hooks

An old code demo revised to use React Hooks.

Multi-Select Checkboxes with React Hooks

I felt like revisiting this bit of code from a few years ago, since it still gets a bit of traffic. You can view the old post at this link.

Here’s the new code using React Hooks, scroll below for a demo:

import React, { useState, useRef, useEffect } from "react";

function generateItems(numberOfItems) {
  return [...Array(numberOfItems).keys()].map((i) => {
    const num = i + 1;
    return { label: `Item ${num}`, id: `value-${num}` };
  });
}

export const ListComponent = () => {
  const listEl = useRef(null);
  const [isShiftDown, setIsShiftDown] = useState(false);
  const [selectedItems, setSelectedItems] = useState([]);
  const [lastSelectedItem, setLastSelectedItem] = useState(null);
  const [items] = useState(generateItems(20));

  function handleKeyUp(e) {
    if (e.key === "Shift") {
      setIsShiftDown(false);
    }
  }

  function handleKeyDown(e) {
    if (e.key === "Shift") {
      setIsShiftDown(true);
    }
  }

  function handleSelectStart(e) {
    // if we're clicking the labels it'll select the text if holding shift
    if (isShiftDown) {
      e.preventDefault();
    }
  }

  function getNewSelectedItems(value) {
    const currentSelectedIndex = items.findIndex((item) => item.id === value);
    const lastSelectedIndex = items.findIndex(
      (item) => item.id === lastSelectedItem,
    );

    return items
      .slice(
        Math.min(lastSelectedIndex, currentSelectedIndex),
        Math.max(lastSelectedIndex, currentSelectedIndex) + 1,
      )
      .map((item) => item.id);
  }

  function getNextValue(value) {
    const hasBeenSelected = !selectedItems.includes(value);

    if (isShiftDown) {
      const newSelectedItems = getNewSelectedItems(value);
      // de-dupe the array using a Set
      const selections = [...new Set([...selectedItems, ...newSelectedItems])];

      if (!hasBeenSelected) {
        return selections.filter((item) => !newSelectedItems.includes(item));
      }

      return selections;
    }

    // if it's already in there, remove it, otherwise append it
    return selectedItems.includes(value)
      ? selectedItems.filter((item) => item !== value)
      : [...selectedItems, value];
  }

  function handleSelectItem(e) {
    const { value } = e.target;
    const nextValue = getNextValue(value);

    setSelectedItems(nextValue);
    setLastSelectedItem(value);
  }

  useEffect(() => {
    document.addEventListener("keyup", handleKeyUp);
    document.addEventListener("keydown", handleKeyDown);
    listEl.current.addEventListener("selectstart", handleSelectStart);

    return () => {
      document.removeEventListener("keyup", handleKeyUp);
      document.removeEventListener("keydown", handleKeyDown);
      listEl.current.removeEventListener("selectstart", handleSelectStart);
    };
  }, [isShiftDown]);

  const listItems = items.map((item) => {
    const { id, label } = item;
    return (
      <li key={id}>
        <input
          onChange={handleSelectItem}
          type="checkbox"
          checked={selectedItems.includes(id)}
          value={id}
          id={`item-${id}`}
        />
        <label htmlFor={`item-${id}`}>{label}</label>
      </li>
    );
  });

  return <ul ref={listEl}>{listItems}</ul>;
};