logo hsb.horse
← Back to snippets index

Snippets

React useSelection Hook

Custom React hook for managing multi-item selection state. Type-safe handling of select all, partial selection, and individual selection.

Published: Updated:

Custom React hook for managing multi-item selection state. Type-safe handling of three states: all selected, some selected, none selected.

Multi-select UIs get messy fast. A table with bulk actions usually needs selected IDs, a header checkbox, an indeterminate state, and a clean way to leave selection mode. useSelection keeps that logic in one place, which makes it a good fit for admin tables, file lists, and similar interfaces.

import { useCallback, useState } from "react";
export type SelectionStatus = "none" | "some" | "all";
export interface UseSelection<T> {
// State
readonly isSelectMode: boolean;
readonly selectedItems: T[];
readonly selectedCount: number;
readonly status: SelectionStatus;
readonly isAllSelected: boolean;
readonly isIndeterminate: boolean;
// Actions
toggleMode: () => void;
toggleAll: () => void;
selectAll: () => void;
clear: () => void;
isSelected: (item: T) => boolean;
onSelect: (item: T) => void;
}
type SelectionState<T> = {
isSelectMode: boolean;
selectedItems: T[];
};
export const SelectionStatus = {
all: "all",
none: "none",
some: "some",
} as const satisfies Record<SelectionStatus, SelectionStatus>;
export const useSelection = <T>(
items: T[],
keyAccessor?: (item: T) => string | number,
): UseSelection<T> => {
const [state, setState] = useState<SelectionState<T>>({
isSelectMode: false,
selectedItems: [],
});
const totalCount = items.length;
const selectedCount = state.selectedItems.length;
const status: SelectionStatus = toSelectionStatus(selectedCount, totalCount);
const isAllSelected = status === SelectionStatus.all;
const isIndeterminate = status === SelectionStatus.some;
const getKey = useCallback(
(item: T) => (keyAccessor ? keyAccessor(item) : item),
[keyAccessor],
);
const toggleMode = useCallback(() => {
setState((prev) => ({
isSelectMode: !prev.isSelectMode,
selectedItems: prev.isSelectMode ? [] : prev.selectedItems,
}));
}, []);
const selectAll = useCallback(() => {
setState((prev) => ({ ...prev, selectedItems: [...items] }));
}, [items]);
const clear = useCallback(() => {
setState((prev) => ({ ...prev, selectedItems: [] }));
}, []);
const toggleAll = useCallback(() => {
setState((prev) => {
const currentCount = prev.selectedItems.length;
const total = items.length;
let currentStatus: SelectionStatus = SelectionStatus.none;
if (total > 0 && currentCount === total)
currentStatus = SelectionStatus.all;
else if (currentCount > 0) currentStatus = SelectionStatus.some;
const shouldSelectAll = currentStatus !== SelectionStatus.all;
return {
...prev,
selectedItems: shouldSelectAll ? [...items] : [],
};
});
}, [items]);
const onSelect = useCallback(
(item: T) => {
const key = getKey(item);
setState((prev) => {
const isSelected = prev.selectedItems.some((i) => getKey(i) === key);
const nextSelectedItems = isSelected
? prev.selectedItems.filter((i) => getKey(i) !== key)
: [...prev.selectedItems, item];
return { ...prev, selectedItems: nextSelectedItems };
});
},
[getKey],
);
const isSelected = useCallback(
(item: T) => {
const key = getKey(item);
return state.selectedItems.some((i) => getKey(i) === key);
},
[state.selectedItems, getKey],
);
return {
isSelectMode: state.isSelectMode,
selectedItems: state.selectedItems,
selectedCount,
status,
isAllSelected,
isIndeterminate,
toggleMode,
toggleAll,
selectAll,
clear,
isSelected,
onSelect,
};
};
function toSelectionStatus(selected: number, total: number): SelectionStatus {
if (total === 0 || selected === 0) return SelectionStatus.none;
if (selected === total) return SelectionStatus.all;
return SelectionStatus.some;
}

Where It Fits

Use it in list UIs that need both per-item toggles and a header-level select-all control. Because the hook returns selectedCount, status, and isIndeterminate together, the view layer can stay thin.

Why This Hook Is Structured This Way

SelectionStatus keeps the three states aligned as none, some, and all, so the UI does not need to rebuild that logic. keyAccessor avoids relying on object identity when items are recreated on each render. toggleMode() also clears the selection when leaving selection mode, which makes bulk-action flows easier to reset.

Notes

selectAll() copies the current items array, so the model is intentionally page-local. If the UI needs cross-page selection or a server-backed selection model, it is safer to split local selection state from a separate global selection state.