여러 항목 선택 상태를 관리하는 React 커스텀 훅. 전체 선택, 일부 선택, 선택 없음의 세 상태를 타입 안전하게 다룬다.
다중 선택 UI는 금방 복잡해진다. 일괄 작업이 있는 테이블은 선택된 ID 배열, 헤더 체크박스, indeterminate 상태, 선택 모드를 빠져나가는 처리까지 필요하다. useSelection은 그 로직을 한곳에 모아 두는 형태라서 관리자 테이블, 파일 목록, 카드 리스트 같은 화면에 잘 맞는다.
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;}이런 UI에 맞다
개별 선택과 전체 선택을 함께 제공해야 하는 목록 화면에 적합하다. selectedCount, status, isIndeterminate를 함께 돌려주기 때문에 view 쪽에서는 헤더 체크박스와 선택 수 표시를 단순하게 유지할 수 있다.
구현 포인트
SelectionStatus가 none, some, all을 한 형태로 묶어 두기 때문에 상태 분기가 UI 쪽에 흩어지지 않는다. keyAccessor를 넣을 수 있어서 매 렌더링마다 새 객체가 생겨도 참조 동일성에 덜 흔들린다. toggleMode()는 선택 모드를 끌 때 선택을 비워 주므로 일괄 작업 뒤 정리도 단순하다.
주의할 점
selectAll()은 현재의 items 배열을 복사하므로 기본적으로 현재 페이지 범위의 선택 모델이다. 페이지를 넘는 선택이나 서버 동기화 상태까지 다루려면, 로컬 선택 상태와 전역 선택 상태를 분리하는 편이 안전하다.
hsb.horse