Hierarchical Selection
Learn how to build hierarchical checkbox selection patterns using foundation components and reusable utilities.
Overview
Hierarchical selection allows users to select items in a parent-child relationship structure, where selecting a parent automatically selects all its children, and selecting all children automatically selects the parent. This pattern is commonly used for category selection, permissions management, location pickers, and filtering interfaces.
Why a Guide?
Rather than providing a rigid component with numerous props, this guide teaches you how to build hierarchical selection patterns using our existing foundation components. This approach:
- Teaches the pattern instead of hiding complexity
- Provides flexibility to adapt to your specific needs
- Uses foundation components like
Disclosure
,Checkbox
, andInput
- Offers reusable utilities that can be copied to your project
- Follows composition principles established in the foundation system
Core Concepts
Data Structure
Hierarchical data follows a simple recursive structure:
interface HierarchicalItem {
id: string;
label: string;
children?: HierarchicalItem[];
disabled?: boolean;
}
Selection Logic
The key behaviors in hierarchical selection are:
- Parent Selection: Selecting a parent selects all enabled children
- Child Selection: When all children are selected, the parent becomes selected
- Indeterminate State: When some (but not all) children are selected, the parent shows indeterminate
- Disabled Items: Disabled items are excluded from parent-child selection logic
Source Code
Data Utilities
These utilities handle the hierarchical data structure and selection logic:
export interface HierarchicalItem {
id: string;
label: string;
children?: HierarchicalItem[];
disabled?: boolean;
}
export interface HierarchicalSelectionState {
selectedIds: Set<string>;
openParentIds: Set<string>;
}
export interface SelectionStatus {
checked: boolean;
indeterminate: boolean;
}
/**
* Flattens a hierarchical data structure to get all items
*/
export function flattenHierarchicalData(
items: HierarchicalItem[]
): HierarchicalItem[] {
return items.reduce<HierarchicalItem[]>((acc, item) => {
acc.push(item);
if (item.children) {
acc.push(...flattenHierarchicalData(item.children));
}
return acc;
}, []);
}
/**
* Gets all child IDs for a given parent item
*/
export function getChildIds(item: HierarchicalItem): string[] {
if (!item.children) return [];
return flattenHierarchicalData(item.children).map((child) => child.id);
}
/**
* Gets all enabled child IDs for a given parent item
*/
export function getEnabledChildIds(item: HierarchicalItem): string[] {
if (!item.children) return [];
return flattenHierarchicalData(item.children)
.filter((child) => !child.disabled)
.map((child) => child.id);
}
/**
* Determines the selection status of a parent item based on its children
*/
export function getParentSelectionStatus(
parentItem: HierarchicalItem,
selectedIds: Set<string>
): SelectionStatus {
if (parentItem.disabled) {
return { checked: false, indeterminate: false };
}
const enabledChildIds = getEnabledChildIds(parentItem);
if (enabledChildIds.length === 0) {
return { checked: false, indeterminate: false };
}
const selectedChildCount = enabledChildIds.filter((id) =>
selectedIds.has(id)
).length;
if (selectedChildCount === 0) {
return { checked: false, indeterminate: false };
} else if (selectedChildCount === enabledChildIds.length) {
return { checked: true, indeterminate: false };
} else {
return { checked: false, indeterminate: true };
}
}
/**
* Gets the selection status for "select all" functionality
*/
export function getSelectAllStatus(
items: HierarchicalItem[],
selectedIds: Set<string>
): SelectionStatus {
const allEnabledIds = flattenHierarchicalData(items)
.filter((item) => !item.disabled)
.map((item) => item.id);
if (allEnabledIds.length === 0) {
return { checked: false, indeterminate: false };
}
const selectedEnabledCount = allEnabledIds.filter((id) =>
selectedIds.has(id)
).length;
if (selectedEnabledCount === 0) {
return { checked: false, indeterminate: false };
} else if (selectedEnabledCount === allEnabledIds.length) {
return { checked: true, indeterminate: false };
} else {
return { checked: false, indeterminate: true };
}
}
/**
* Filters hierarchical data based on a search query
*/
export function filterHierarchicalData(
items: HierarchicalItem[],
searchQuery: string
): HierarchicalItem[] {
if (!searchQuery.trim()) return items;
const query = searchQuery.toLowerCase();
return items
.map((item) => {
const matchingChildren = item.children?.filter((child) =>
child.label.toLowerCase().includes(query)
);
if (item.label.toLowerCase().includes(query)) {
return item;
} else if (matchingChildren?.length) {
return { ...item, children: matchingChildren };
}
return null;
})
.filter(Boolean) as HierarchicalItem[];
}
Selection Hook
The useHierarchicalSelection
hook provides state management and selection methods:
"use client";
import { useCallback, useMemo, useState } from "react";
import {
flattenHierarchicalData,
getEnabledChildIds,
getParentSelectionStatus,
getSelectAllStatus,
HierarchicalItem,
HierarchicalSelectionState,
SelectionStatus,
} from "./hierarchical-data";
export interface UseHierarchicalSelectionOptions {
/** Initial selected item IDs */
defaultSelected?: string[];
/** Initial opened parent IDs */
defaultOpened?: string[];
/** Callback when selection changes */
onSelectionChange?: (selectedIds: string[]) => void;
/** Callback when opened parents change */
onOpenChange?: (openedIds: string[]) => void;
}
export interface UseHierarchicalSelectionResult {
/** Current selected item IDs as a Set for efficient lookup */
selectedIds: Set<string>;
/** Current opened parent IDs as a Set for efficient lookup */
openParentIds: Set<string>;
/** Current selected item IDs as an array */
selectedArray: string[];
/** Current opened parent IDs as an array */
openedArray: string[];
/** Toggle selection of a single item */
toggleItem: (itemId: string) => void;
/** Select/deselect a parent and all its enabled children */
toggleParent: (parentItem: HierarchicalItem, checked: boolean) => void;
/** Select/deselect all enabled items */
toggleAll: (items: HierarchicalItem[], checked: boolean) => void;
/** Toggle the opened state of a parent */
toggleParentOpen: (parentId: string) => void;
/** Clear all selections */
clearSelection: () => void;
/** Select specific items */
selectItems: (itemIds: string[]) => void;
/** Get selection status for a parent item */
getParentStatus: (parentItem: HierarchicalItem) => SelectionStatus;
/** Get selection status for "select all" */
getSelectAllStatus: (items: HierarchicalItem[]) => SelectionStatus;
/** Check if an item is selected */
isSelected: (itemId: string) => boolean;
/** Check if a parent is opened */
isParentOpen: (parentId: string) => boolean;
}
export function useHierarchicalSelection(
options: UseHierarchicalSelectionOptions = {}
): UseHierarchicalSelectionResult {
const {
defaultSelected = [],
defaultOpened = [],
onSelectionChange,
onOpenChange,
} = options;
// Internal state
const [state, setState] = useState<HierarchicalSelectionState>({
selectedIds: new Set(defaultSelected),
openParentIds: new Set(defaultOpened),
});
// Derived values
const selectedArray = useMemo(
() => Array.from(state.selectedIds),
[state.selectedIds]
);
const openedArray = useMemo(
() => Array.from(state.openParentIds),
[state.openParentIds]
);
// Update callbacks when arrays change
const notifySelectionChange = useCallback(
(newSelectedIds: Set<string>) => {
const newArray = Array.from(newSelectedIds);
onSelectionChange?.(newArray);
},
[onSelectionChange]
);
const notifyOpenChange = useCallback(
(newOpenIds: Set<string>) => {
const newArray = Array.from(newOpenIds);
onOpenChange?.(newArray);
},
[onOpenChange]
);
// Selection methods
const toggleItem = useCallback(
(itemId: string) => {
setState((prev) => {
const newSelectedIds = new Set(prev.selectedIds);
if (newSelectedIds.has(itemId)) {
newSelectedIds.delete(itemId);
} else {
newSelectedIds.add(itemId);
}
notifySelectionChange(newSelectedIds);
return {
...prev,
selectedIds: newSelectedIds,
};
});
},
[notifySelectionChange]
);
const toggleParent = useCallback(
(parentItem: HierarchicalItem, checked: boolean) => {
if (parentItem.disabled) return;
setState((prev) => {
const newSelectedIds = new Set(prev.selectedIds);
const enabledChildIds = getEnabledChildIds(parentItem);
if (checked) {
enabledChildIds.forEach((id) => newSelectedIds.add(id));
} else {
enabledChildIds.forEach((id) => newSelectedIds.delete(id));
}
notifySelectionChange(newSelectedIds);
return {
...prev,
selectedIds: newSelectedIds,
};
});
},
[notifySelectionChange]
);
const toggleAll = useCallback(
(items: HierarchicalItem[], checked: boolean) => {
setState((prev) => {
const allEnabledIds = flattenHierarchicalData(items)
.filter((item) => !item.disabled)
.map((item) => item.id);
let newSelectedIds: Set<string>;
if (checked) {
newSelectedIds = new Set([...prev.selectedIds, ...allEnabledIds]);
} else {
newSelectedIds = new Set(
Array.from(prev.selectedIds).filter(
(id) => !allEnabledIds.includes(id)
)
);
}
notifySelectionChange(newSelectedIds);
return {
...prev,
selectedIds: newSelectedIds,
};
});
},
[notifySelectionChange]
);
const toggleParentOpen = useCallback(
(parentId: string) => {
setState((prev) => {
const newOpenIds = new Set(prev.openParentIds);
if (newOpenIds.has(parentId)) {
newOpenIds.delete(parentId);
} else {
newOpenIds.add(parentId);
}
notifyOpenChange(newOpenIds);
return {
...prev,
openParentIds: newOpenIds,
};
});
},
[notifyOpenChange]
);
const clearSelection = useCallback(() => {
setState((prev) => {
const newSelectedIds = new Set<string>();
notifySelectionChange(newSelectedIds);
return {
...prev,
selectedIds: newSelectedIds,
};
});
}, [notifySelectionChange]);
const selectItems = useCallback(
(itemIds: string[]) => {
setState((prev) => {
const newSelectedIds = new Set(itemIds);
notifySelectionChange(newSelectedIds);
return {
...prev,
selectedIds: newSelectedIds,
};
});
},
[notifySelectionChange]
);
// Status methods
const getParentStatus = useCallback(
(parentItem: HierarchicalItem): SelectionStatus => {
return getParentSelectionStatus(parentItem, state.selectedIds);
},
[state.selectedIds]
);
const getSelectAllStatusResult = useCallback(
(items: HierarchicalItem[]): SelectionStatus => {
return getSelectAllStatus(items, state.selectedIds);
},
[state.selectedIds]
);
const isSelected = useCallback(
(itemId: string): boolean => {
return state.selectedIds.has(itemId);
},
[state.selectedIds]
);
const isParentOpen = useCallback(
(parentId: string): boolean => {
return state.openParentIds.has(parentId);
},
[state.openParentIds]
);
return {
selectedIds: state.selectedIds,
openParentIds: state.openParentIds,
selectedArray,
openedArray,
toggleItem,
toggleParent,
toggleAll,
toggleParentOpen,
clearSelection,
selectItems,
getParentStatus,
getSelectAllStatus: getSelectAllStatusResult,
isSelected,
isParentOpen,
};
}
Examples
Basic Implementation
A simple parent-child checkbox structure with collapsible sections using the Disclosure
component:
With Search Functionality
Adding search capabilities to filter the hierarchy while preserving the selection state:
Complex Example
A full-featured implementation with disabled items, search, select-all, and action buttons:
Form Integration
Integration with react-hook-form
showing how to use hierarchical selection in forms:
Building Your Own
Step 1: Set Up the Data Structure
Define your hierarchical data using the HierarchicalItem
interface:
const data: HierarchicalItem[] = [
{
id: "frontend",
label: "Frontend",
children: [
{ id: "react", label: "React" },
{ id: "vue", label: "Vue" },
{ id: "angular", label: "Angular" },
],
},
{
id: "backend",
label: "Backend",
children: [
{ id: "node", label: "Node.js" },
{ id: "python", label: "Python" },
{ id: "java", label: "Java" },
],
},
];
Step 2: Initialize the Hook
Set up the selection hook with your desired options:
const {
selectedArray,
toggleItem,
toggleParent,
toggleParentOpen,
getParentStatus,
isSelected,
isParentOpen,
} = useHierarchicalSelection({
defaultSelected: ["react"],
defaultOpened: ["frontend"],
onSelectionChange: (selected) => {
console.log("Selected:", selected);
},
});
Step 3: Build the UI
Compose the UI using foundation components:
return (
<div className="space-y-2">
{data.map((parent) => {
const parentStatus = getParentStatus(parent);
const isOpen = isParentOpen(parent.id);
return (
<Disclosure key={parent.id} open={isOpen}>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2">
<Checkbox
checked={parentStatus.checked}
indeterminate={parentStatus.indeterminate}
onChange={(e) => toggleParent(parent, e.target.checked)}
/>
<span>{parent.label}</span>
</label>
<DisclosureTrigger onClick={() => toggleParentOpen(parent.id)}>
<DisclosureChevron />
</DisclosureTrigger>
</div>
<DisclosureContent>
<div className="ml-6 space-y-2">
{parent.children?.map((child) => (
<label key={child.id} className="flex items-center gap-2">
<Checkbox
checked={isSelected(child.id)}
onChange={() => toggleItem(child.id)}
/>
<span>{child.label}</span>
</label>
))}
</div>
</DisclosureContent>
</Disclosure>
);
})}
</div>
);
Advanced Features
Adding Search
Use the filterHierarchicalData
utility to add search functionality:
const [searchQuery, setSearchQuery] = useState("");
const filteredData = filterHierarchicalData(data, searchQuery);
Select All Functionality
Implement select-all with the toggleAll
method and getSelectAllStatus
utility:
const selectAllStatus = getSelectAllStatus(data);
<Checkbox
checked={selectAllStatus.checked}
indeterminate={selectAllStatus.indeterminate}
onChange={(e) => toggleAll(data, e.target.checked)}
/>
Disabled Items
Mark items as disabled in your data structure:
const dataWithDisabled: HierarchicalItem[] = [
{
id: "features",
label: "Features",
disabled: true, // Entire section disabled
children: [
{ id: "feature-a", label: "Feature A" },
{ id: "feature-b", label: "Feature B", disabled: true }, // Individual item disabled
],
},
];
Form Integration
Create a controlled component for forms:
function HierarchicalField({ value, onChange, data, error }) {
const selection = useHierarchicalSelection({
defaultSelected: value,
onSelectionChange: onChange,
});
return (
<div>
{/* Your hierarchical selection UI */}
{error && <p className="text-red-600">{error}</p>}
</div>
);
}
// In your form
<Controller
name="selectedItems"
control={control}
render={({ field }) => (
<HierarchicalField
value={field.value}
onChange={field.onChange}
data={hierarchicalData}
error={errors.selectedItems?.message}
/>
)}
/>
Best Practices
Data Structure
- Use meaningful IDs: Make IDs descriptive for better debugging
- Keep data flat: Avoid deep nesting beyond 2-3 levels
- Consider disabled states: Plan for items that shouldn't be selectable
- Separate display from logic: Keep business logic separate from display logic
Performance
- Memoize filtered data: Use
useMemo
for filtered results in search - Debounce search: Prevent excessive filtering on every keystroke
- Virtualize large lists: Consider virtualization for hundreds of items
- Lazy load children: Load child data on demand for large datasets
Accessibility
- Use proper labels: Associate labels with checkboxes correctly
- Keyboard navigation: Ensure all interactions work with keyboard
- Screen reader support: Use
aria-expanded
and proper roles - Focus management: Maintain logical focus flow
User Experience
- Preserve selection: Maintain selections when searching or filtering
- Show selection count: Display how many items are selected
- Quick actions: Provide shortcuts like "Select All" or preset selections
- Clear feedback: Show loading states and error messages clearly
Common Patterns
Location Picker
const locationData = [
{
id: "north-america",
label: "North America",
children: [
{ id: "usa", label: "United States" },
{ id: "canada", label: "Canada" },
],
},
];
Permissions Management
const permissionData = [
{
id: "user-management",
label: "User Management",
children: [
{ id: "create-users", label: "Create Users" },
{ id: "edit-users", label: "Edit Users" },
{ id: "delete-users", label: "Delete Users" },
],
},
];
Category Selection
const categoryData = [
{
id: "electronics",
label: "Electronics",
children: [
{ id: "smartphones", label: "Smartphones" },
{ id: "laptops", label: "Laptops" },
{ id: "tablets", label: "Tablets" },
],
},
];