New to Kendo UI for AngularStart a free 30-day trial

Maintaining Consistent TreeView Check State with Load on Demand and Filtering

Updated on Mar 11, 2026

Environment

ProductProgress® Kendo UI for Angular TreeView

Description

The TreeView uses loadOnDemand set to true by default. When a parent node is checked while some of its children are hidden by a filter, all visible children get checked after the parent node expands. This behavior occurs because the children load dynamically when the node expands, and the built-in kendoTreeViewCheckable directive checks all loaded children of a checked parent.

When loadOnDemand is set to false, the children are already loaded and their individual checked state is preserved. The visible children that are not checked remain unchecked after the parent expands.

This Knowledge Base article also answers the following questions:

  • How can I control the checked state of dynamically loaded or filtered TreeView nodes in a consistent and predictable way?
  • How can I implement a custom check directive that derives the checked state from the data model instead of relying on the built-in directive's internal state?
  • How can I ensure that the checked state of parent nodes reflects the state of their children, even when some children are hidden by a filter?

Solution

Replace the built-in kendoTreeViewCheckable directive with a fully custom check directive. This approach gives you complete control over the checked state logic through the isChecked callback.

  1. Create a CustomCheckDirective that stores only leaf node references in a Set, using data item object references. Using object references ensures that the checked state remains stable when filtering reshapes the tree and reassigns node indices.

    ts
    @Directive({ selector: '[customCheck]', standalone: true })
    export class CustomCheckDirective implements OnInit, OnDestroy {
        @Input() public checkedKeys: string[] = [];
        @Output() public checkedKeysChange = new EventEmitter<string[]>();
    
        // Tracks checked state by data item object reference
        private checkedDataItems = new Set<object>();
        // Tracks parent nodes checked before their children load
        private pendingParentChecks = new Set<object>();
    }
  2. Subscribe to the checkedChange event to toggle nodes when a user clicks a checkbox. Subscribe to the childrenLoaded event to check children of parent nodes that were checked before their children loaded. Bind a custom isChecked callback that derives the checked state from the data model.

    ts
    @Directive({ selector: '[customCheck]', standalone: true })
    export class CustomCheckDirective implements OnInit, OnDestroy {
        ...
        private subscriptions = new Subscription();
    
        constructor(
            private treeView: TreeViewComponent,
            private cdr: ChangeDetectorRef
        ) {}
    
        public ngOnInit(): void {
            this.subscriptions.add(
                this.treeView.checkedChange.subscribe((node) => {
                    this.toggleNode(node);
                    this.emitChanges();
                })
            );
    
            this.subscriptions.add(
                this.treeView.childrenLoaded.subscribe((e) =>
                    this.handleChildrenLoaded(e)
                )
            );
    
            this.treeView.isChecked = this.isItemChecked.bind(this);
        }
    }
  3. Implement the isItemChecked callback to derive the parent state from the data model children. For leaf nodes, look up the data item reference in checkedDataItems. For parent nodes that are awaiting children load, return 'checked'. For other parent nodes, recursively evaluate child states from dataItem.items. Deriving the state from the data model guarantees a correct 'checked', 'indeterminate', or 'none' state regardless of active filters.

    ts
    private isItemChecked(dataItem: any, _index: string): CheckedState {
        if (!dataItem.items || dataItem.items.length === 0) {
            return this.checkedDataItems.has(dataItem) ? 'checked' : 'none';
        }
    
        if (this.pendingParentChecks.has(dataItem)) {
            return 'checked';
        }
    
        let checkedCount = 0;
        let hasIndeterminate = false;
    
        for (let i = 0; i < dataItem.items.length; i++) {
            const childState = this.isItemChecked(dataItem.items[i], `${_index}_${i}`);
            if (childState === 'checked') {
                checkedCount++;
            } else if (childState === 'indeterminate') {
                hasIndeterminate = true;
            }
        }
    
        if (checkedCount === dataItem.items.length) return 'checked';
        if (checkedCount > 0 || hasIndeterminate) return 'indeterminate';
        return 'none';
    }
  4. Implement the toggleNode method to propagate the check state to all loaded children when a parent is checked. If a parent node has no loaded children (load-on-demand), track it as pending so the directive checks the children when they load. When a parent is unchecked, recursively remove all descendant data item references from the set. Implement the handleChildrenLoaded method to check the children of pending parent nodes when they load.

    ts
    private toggleNode(node: TreeItemLookup, forceCheck?: boolean): void {
        const dataItem = node.item.dataItem;
        const currentState = this.isItemChecked(dataItem, node.item.index);
        const shouldCheck = forceCheck ?? currentState !== 'checked';
    
        if (!shouldCheck) {
            this.removeDataItemAndDescendants(dataItem);
            this.pendingParentChecks.delete(dataItem);
            return;
        }
    
        if (!dataItem.items || dataItem.items.length === 0) {
            this.checkedDataItems.add(dataItem);
        } else if (node.children.length > 0) {
            node.children.forEach((child) => this.toggleNode(child, true));
        } else {
            this.pendingParentChecks.add(dataItem);
        }
    }
    
    private removeDataItemAndDescendants(dataItem: any): void {
        this.checkedDataItems.delete(dataItem);
        if (dataItem.items) {
            for (const child of dataItem.items) {
                this.removeDataItemAndDescendants(child);
            }
        }
    }
    
    private handleChildrenLoaded(e: any): void {
        const parentDataItem = e.item.dataItem;
        if (this.pendingParentChecks.has(parentDataItem)) {
            e.children.forEach((child: any) => {
                if (child.dataItem?.items?.length > 0) {
                    this.pendingParentChecks.add(child.dataItem);
                } else {
                    this.checkedDataItems.add(child.dataItem);
                }
            });
            this.pendingParentChecks.delete(parentDataItem);
            this.emitChanges();
            this.cdr.markForCheck();
        }
    }
  5. Configure the TreeView with [filterable]="true". Handle the filterChange event to filter the data source manually and auto-expand parent nodes that contain matching children.

    html
    <kendo-treeview
        [nodes]="parsedData"
        textField="text"
        kendoTreeViewExpandable
        [(expandedKeys)]="expandedKeys"
        [children]="children"
        [hasChildren]="hasChildren"
        customCheck
        [(checkedKeys)]="checkedKeys"
        [filterable]="true"
        (filterChange)="handleFilter($event)"
    ></kendo-treeview>

The following example demonstrates the implementation of the custom check directive with filtering. To test the behavior, filter by bed, check the Furniture node, and then remove the filter. The Furniture parent displays an indeterminate state because the Occasional Furniture child remains unchecked.

Change Theme
Theme
Loading ...

See Also

In this article
EnvironmentDescriptionSolutionSee Also
Not finding the help you need?
Contact Support