Skip to content

Instantly share code, notes, and snippets.

@librz
Created March 16, 2026 08:26
Show Gist options
  • Select an option

  • Save librz/516fa85d50f014ad381899c34827a39c to your computer and use it in GitHub Desktop.

Select an option

Save librz/516fa85d50f014ad381899c34827a39c to your computer and use it in GitHub Desktop.
A react hook for handling list select (including support for select all)
import { groupBy } from "es-toolkit";
import { useCallback, useMemo, useState } from "react";
type SelectAllControl = {
disabled: boolean;
checkedState: boolean | "indeterminate";
onToggleSelect: () => void;
};
type ItemSelectControl<T> = {
isSelected: (item: T) => boolean;
onToggleSelect: (item: T) => void;
};
export type ListSelectControl<T> = {
selectedItems: T[];
all: SelectAllControl;
item: ItemSelectControl<T>;
clearSelect: () => void;
};
/**
* hooks for implementing select in list
* @param items list data
* - provide current page list if select all mode is current-page
* - provide list of all pages if select all mode is cross-page
* - pass in filtered items if there's search involved
* @param idKey name of id that uniquely identifies a list item
* @param options.isItemDisabled whether an item is disabled or not
* @returns select control
*/
export function useListSelect<T, K extends keyof T>(
items: T[],
idKey: K,
options?: {
isItemDisabled?: (item: T) => boolean;
},
): ListSelectControl<T> {
const { isItemDisabled } = options ?? {};
const selectableItems: T[] = useMemo(() => {
if (!isItemDisabled) {
return items;
}
return items.filter((it) => !isItemDisabled(it));
}, [items, isItemDisabled]);
/**
* using map instead of array for perf (O(1) when search, delete or append)
*/
const [selectedItemsMap, setSelectedItemsMap] = useState<Map<T[K], T>>(new Map());
const selectedItems: T[] = useMemo(() => {
return Array.from(selectedItemsMap.values());
}, [selectedItemsMap]);
const selectAllDisabled: boolean = selectableItems.length === 0;
const selectAllChecked: boolean | "indeterminate" = useMemo(() => {
if (selectableItems.length === 0) return false;
const { selected = [], unselected = [] } = groupBy(selectableItems, (it) =>
selectedItemsMap.has(it[idKey]) ? "selected" : "unselected",
);
if (selected.length === 0) {
return false;
}
if (unselected.length === 0) {
return true;
}
return "indeterminate";
}, [selectableItems, selectedItemsMap, idKey]);
const isItemSelected = useCallback(
(item: T) => {
const id = item[idKey];
return selectedItemsMap.has(id);
},
[selectedItemsMap, idKey],
);
const clearSelect = useCallback(() => {
setSelectedItemsMap(new Map());
}, []);
const toggleItemSelect = useCallback(
(item: T) => {
setSelectedItemsMap((map) => {
const newMap = new Map(map);
const id = item[idKey];
if (map.has(id)) {
newMap.delete(id);
} else {
newMap.set(id, item);
}
return newMap;
});
},
[idKey],
);
const toggleSelectAll = useCallback(() => {
setSelectedItemsMap((map) => {
const newMap = new Map(map);
const allItemsSelected = selectableItems.every((it) => map.has(it[idKey]));
selectableItems.forEach((it) => {
const mapKey = it[idKey];
if (allItemsSelected) {
// action: uncheck all
newMap.delete(mapKey);
} else {
// action: check all
newMap.set(mapKey, it);
}
});
return newMap;
});
}, [selectableItems, idKey]);
const selectAllControl: SelectAllControl = {
disabled: selectAllDisabled,
checkedState: selectAllChecked,
onToggleSelect: toggleSelectAll,
};
const itemSelectControl: ItemSelectControl<T> = {
isSelected: isItemSelected,
onToggleSelect: toggleItemSelect,
};
const selectControl: ListSelectControl<T> = {
selectedItems,
clearSelect,
all: selectAllControl,
item: itemSelectControl,
};
return selectControl;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment