Maintaining Consistent TreeView Check State with Load on Demand and Filtering
Environment
| Product | Progress® 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.
-
Create a
CustomCheckDirectivethat stores only leaf node references in aSet, 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>(); } -
Subscribe to the
checkedChangeevent to toggle nodes when a user clicks a checkbox. Subscribe to thechildrenLoadedevent to check children of parent nodes that were checked before their children loaded. Bind a customisCheckedcallback 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); } } -
Implement the
isItemCheckedcallback to derive the parent state from the data model children. For leaf nodes, look up the data item reference incheckedDataItems. For parent nodes that are awaiting children load, return'checked'. For other parent nodes, recursively evaluate child states fromdataItem.items. Deriving the state from the data model guarantees a correct'checked','indeterminate', or'none'state regardless of active filters.tsprivate 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'; } -
Implement the
toggleNodemethod 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 thehandleChildrenLoadedmethod to check the children of pending parent nodes when they load.tsprivate 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(); } } -
Configure the TreeView with
[filterable]="true". Handle thefilterChangeevent 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.