logo hsb.horse
← スニペット一覧に戻る

Snippets

React useSelection フック

React で複数アイテムの選択状態を管理するカスタムフック。全選択、部分選択、個別選択を型安全に扱う。

公開日: 更新日:

翻訳

Reactで複数アイテムの選択状態を管理するカスタムフック。全選択、部分選択、個別選択の3状態を型安全に扱える。

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;
}