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, and Input
  • 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:

  1. Parent Selection: Selecting a parent selects all enabled children
  2. Child Selection: When all children are selected, the parent becomes selected
  3. Indeterminate State: When some (but not all) children are selected, the parent shows indeterminate
  4. 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

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" },
    ],
  },
];