React hook to select multiple items with a shift
Table of Contents
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?