Multi-Select Checkboxes with React

An issue arose recently where a user wanted to be able to hold the Shift key to select a range of items in a list. A pal in work set me off on the right path, but what ended up being approved was quite different as there were multiple things to consider:

Here's what we'll be making. Try checking off an item then hold the Shift key and click on another item further down the list.

View Demo

The Component #

Here's the scaffold of the component we'll be making. This looks a bit chunky, but we'll go through the methods individually and fill them in.

import React, { PureComponent } from "react";

export default class List extends PureComponent {
// set up state and bindings
constructor() {}

// add event listeners
componentDidMount() {}

// remove event listeners
componentWillUnmount() {}

// handle the `onselectstart` event to prevent it interfering with bulk selection
handleSelectStart(e) {}

// listen for the shift keyup
handleKeyUp(e) {}

// listen for the shift keydown
handleKeyDown(e) {}

// handle toggle checkboxes
handleSelectItem(e) {}

// get the next value or range of values to select
getNextValue(value) {}

// get the range of items selected with the shift key held down
getNewSelectedItems(value) {}

// render our list items
renderItems() {}

render() {
return <ul ref={node => (this.listEl = node)}>{this.renderItems()}</ul>;

renderItems #

We'll start with rendering the items, as a bunch of code later on will depend on this. We'll be iterating over an array of objects that have a label and id property. The id is what we'll keep track of in state, and we'll know if it's selected be checking if it's in the selectedItems array.

renderItems() {
const { items, selectedItems } = this.state;
return => {
const { id, label } = item;
return (
<li key={id}>
<label htmlFor={`item-${id}`}>{label}</label>

constructor #

We'll start by setting up the initial state, and binding events to the component.

constructor() {

this.state = {
items: generateItems(20),
isShiftDown: false,
selectedItems: [],
lastSelectedItem: null

this.listEl = null;

this.handleKeyUp = this.handleKeyUp.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleSelectItem = this.handleSelectItem.bind(this);
this.handleSelectStart = this.handleSelectStart.bind(this);

First, we'll need some data to iterate over. The generateItems method is a small utility function for generating some example data. It goes like this:

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

The next bit, isShiftDown will keep track of when the Shift key is currently being pressed. That's how we'll know when to trigger our multi-selection.

selectedItems will keep track of what has been checked off. We'll use this to toggle items, and also determine if we should be bulk selecting or unselecting items.

Next, if I click item 5 and then hold shift and click item 10, I need to select items 5 through 10. The lastSelectedItem will help us do that.

listEl will be a reference to our list element, and finally, we'll bind our events to the component. We could use arrow functions as well, but this is how I was raised.

componentDidMount #

We're listening for DOM events, pure and simple:

componentDidMount() {
document.addEventListener("keyup", this.handleKeyUp, false);
document.addEventListener("keydown", this.handleKeyDown, false);
this.listEl.addEventListener("selectstart", this.handleSelectStart, false);

The key up/down events will be for detecting when the Shift key is being pressed.

The selectstart event is when we're clicking on text, and holding the Shift key. If we do that, it'll select the text rather than the range of items we want. By listening for this, we can decide whether we want to allow the default behaviour or block it.

componentWillUnmount #

This is for cleanup so we don't leave any event listeners hanging around which might try update state on a component that no longer exists on the page.

componentWillUnmount() {
document.removeEventListener("keyup", this.handleKeyUp);
document.removeEventListener("keydown", this.handleKeyDown);
this.listEl.removeEventListener("selectstart", this.handleSelectStart);

handleSelectStart #

To be fair, this could be named a bit better to avoid confusion with other selections we'll be making, but it mirrors the DOM event we're handling:

handleSelectStart(e) {
if (this.state.isShiftDown) {

We only want to disable the text selection if the Shift key is being held down. This way a user can still highlight the items and copy them if they wish.

handleKeyUp and handleKeyDown #

We do a quick check if the Shift key is be pressed, or let go of, and update the state accordingly.

handleKeyUp(e) {
if (e.key === "Shift" && this.state.isShiftDown) {
this.setState({ isShiftDown: false });

handleKeyDown(e) {
if (e.key === "Shift" && !this.state.isShiftDown) {
this.setState({ isShiftDown: true });

handleSelectItem #

This is a short and sweet method that will hand off the majority of the work. This will make a call to get the next value(s) for our selectedItems and update the state. We'll also store the value as the lastSelectedItem for when the next click is made with the Shift key held down.

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

this.setState({ selectedItems: nextValue, lastSelectedItem: value });

getNextValue #

This is where things get a little more involved. Let's ignore the if (isShiftDown) block for now and skip to the final return; if the item is already selected, then we return the selectedItems without that value. Otherwise, we append it to the array.

If, however, the isShiftDown is true, we'll call getNewSelectedItems to return an array of items. It's combined with the currently selected items and duplicates are removed using a Set which is spread into an array. Sets cannot contain duplicates, so if you try to add an item that already exists, it won't do anything.

If the item isn't in the selectedItems array, we know then that it was previously removed. With that we remove anything that isn't a newly selected item - this inverts the selection.

getNextValue(value) {
const { isShiftDown, selectedItems } = this.state;
const hasBeenSelected = !selectedItems.includes(value);

if (isShiftDown) {
const newSelectedItems = this.getNewSelectedItems(value);
// de-dupe the array using a Set
const selections = [ 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];

getSelectedItems #

In this final method, we get the index of the last item we selected, and the current one. We then grab the range of those items using Math.min and Math.max to get the lower and upper index from the items array. Rather than returning the array of objects, we map over the results to return only the id.

getNewSelectedItems(value) {
const { lastSelectedItem, items } = this.state;
const currentSelectedIndex = items.findIndex(item => === value);
const lastSelectedIndex = items.findIndex(
item => === lastSelectedItem

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

It took a while to get this right, and even then there's probably plenty of room for improvement. It was an enjoyable problem to solve. I tried doing it with regular JavaScript, and it's a whole different ball game when the state is stored in the DOM instead. My mind has been in React land for the past 2 years, so I never thought of it any other way. It'd be a good exercise to try this out in other frameworks/paradigms.

View Demo and Source

← Home