Written by Lewis Cianci✏️
There could be a few different reasons you’re here right now reading this article. Maybe you simply saw the title and thought it’d be interesting. Or maybe you’ve been trying to get the Angular Material <cdk-tree>
to work for what feels like months, your efforts peaking in what can only be described as a hyperfixation.
If the latter case is true, I understand your frustration. You’ve become determined to not let the tree “beat you,” but no matter what you do, bouncing between the various StackOverflow topics, the documentation, and CodePens from ten years ago, the tree just won’t work for you.
Your phone has tens of missed calls from friends checking on your wellbeing. When other people look outside, they see a beautiful sky, birds, wildlife. When you look outside, you see nature as an expanding and collapsing hierarchy. There is no life. There is only tree.
It’s a little dramatic. But the Angular tree is not easy to use or understand. The technical implementation is good — great, even. But the available documentation makes using the tree nigh on impossible.
I wrote this nearly 6,000 word behemoth article for one reason — to attempt to resolve this. Everything in this article has been tested with Angular 18, and even uses updated bits like signals to make life a little easier. To that end, we’re going to take it slow, starting with simple examples and building up from there.
What makes the Angular tree so difficult to use?
Clearly, it seems like I’m ragging on the Angular Material <cdk-tree>
. That’s not the intent. However, after trying to use the Angular tree for days and being flat-out bewildered, I did wonder: how did it get to this point?
Well, it starts with the official documentation, which I found simultaneously light on details and overly verbose. Reading the docs felt something like reading a pizza recipe that goes into pages of detail about where pizza came from, but stops short of telling you whether to put the cheese on first or last.
The final descent into chaos came from desperately searching online for anyone else who had done this successfully. Instead of answers, I got hit with examples for all versions of Angular and GitHub issues in the Angular repository opened by people saying their tree didn’t work.
Put simply — there’s just no comprehensive guide online on how to use the Angular tree.
Despite this, there are many good reasons to use first-party components like the tree view in Angular! First, writing a new tree from scratch is reinventing the wheel.
Second, whenever you upgrade to the next major version of Angular, using a first-party component from the Angular CDK or Angular Material will (probably 😬) upgrade just fine. You won’t be stuck with a component that hasn’t been updated since it was written five years ago, with dependency issues that you have to sort out.
As an actual component, the tree view can be made to work well in Angular. It works with everything you could ever desire from a tree, like checkboxes, asynchronous loading, the list goes on.
But first, a warning. If you’ve read the Angular CDK documentation on the tree or the Angular Material overview of the tree component, basically, it’s not relevant to this article.
Unfortunately, the official documentation makes the tree harder to use than it actually is, and doesn’t explain some of the trickier concepts. If you read the official documentation and it didn’t make sense to you, you’re in good company.
In the beginning, there was the CDK Tree
Most Angular developers likely know what Angular Material is. But they might give you puzzled stares if you start talking about the Angular CDK, which underpins a lot of the functionality in Angular Material, like scrolling or dragging-and-drop.
The first obvious question that springs to mind is, why? Well, you might love Angular Material, but not everyone likes the styling, and other developers have a certain corporate styling they must work with.
So, the CDK tree gives you the implementation of a tree view in Angular, but without all of the styling, so you can make it look how you want. Meanwhile, the Material tree brings a styled tree view that you could throw into your Angular Material application.
It would be fair to say that they are essentially the same thing. Their main difference is just that the Angular Material tree view has some nice styling on it.
By the end of this article, we’ll have an app that uses the tree, but will also:
- Dynamically load more data into an existing tree
- Have nodes that are selectable, or have checkboxes
- Load children of nodes asynchronously
- Have a hierarchical view of nodes to help you see the relationships between collapsed and expanded nodes
Let’s get into it.
Barking up the wrong (Angular) tree
The first thing we have to address with the tree is that there are two types of trees to use:
- Flat tree: Each node appears sequentially in the DOM. Even when nodes are expanded, they only ever appear “alongside” other nodes
- Nested tree: Each node can contain other trees of nodes. When a node is expanded, the expanded tree is displayed within the parent node
To expand on this, within the browser, the flat tree would look like this: Did you notice how Node 2 is expanded, but the children are still on individual lines? That’s because each node appears sequentially in the browser, regardless of level.
The nested tree, on the other hand, would look more like this: Ah, wow — it looks exactly the same, apart from that green box. The green box is an outlet for the tree. When you expand a node, its children is rendered into the outlet. If those nodes have children, the process repeats.
So, what one should you use? It will depend based on your use case. But, if I’m being honest, I don’t think the flat tree should even be an option. It’s more complex than the nested tree, with no benefits or advantages. But I’ll get into this in greater detail later on.
Setting up an Angular project with a tree view
Most guides online, including the official Angular documentation, use static data in their tree. However, it’s unlikely that you’ll always know what you want to be in your tree when you stand it up. Once your app and API start serving up even the most non-trivial amount of data, it will make sense to lazy-load the children in your tree.
To facilitate this requirement, I’ve generated a simple API in Node.js, which provides a long list of data, and other API actions that get details on Pokémon to show more of a practical use case.
Let’s look at the “long list” API first. If we were to call this API endpoint with no parameters, we’ll receive:
[
"Item 1",
"Item 2",
"Item 3",
"Item 4",
"Item 5",
"Item 6",
"Item 7",
"Item 8",
"Item 9",
"Item 10"
]
If we skip five items and take five items, we’ll receive:
[
"Item 6",
"Item 7",
"Item 8",
"Item 9",
"Item 10"
]
Note that in my example, I’m using OpenAPI (previously Swagger) on the API for documentation, but I’m also using the OpenAPI tooling to generate an API client for my Angular app. Ultimately, however you receive data into your application is up to you. Whether you’re using HTTP get requests or reading from the filesystem, you should be able to adapt the tree for your needs.
One notable thing about the server code: I’ve introduced a delay on responses of three seconds on line 12 of the index.js
file. The reason is because operations that run on the server may be naturally delayed due to a database lookup or similar.
For this tutorial, I’ve artificially created this delay to give the feel of a more realistic real-world application, as well as so that we can see how to set nodes to loading
. The server code is in this GitHub repository for you to review or fork as needed.
Creating the data models
In our case, the server will return LongDataItem
object, which will contain some text and an index number. A standard LongDataItem
object would look like this:
{
text: 'Item 1'
index: 1
}
This is all the information the server has on the node, but it lacks information that we need to make our tree view work. For example, we need to know if a node is expandable, or if it’s loading.
To that end, let’s create a class that has the LongDataItem
object, but also contains the information that we need:
export class TreeNode {
expandable = signal(true);
loading = signal(false);
options = signal<Set<TreeOption>>(new Set<TreeOption>())
constructor(public level: number, public data: LongDataItem) {
}
}
export enum TreeOption {
Last,
Highlighted
}
For our sample, every single node will be expandable. On properties that we expect to change, we use signals, first introduced in Angular 16, so we can update those values in the future. We also use Set
to add or remove options to a given node — for example, if it’s the last node in a tree, or if it’s been highlighted.
Creating a pipe
to help with types
Whether you use FlatTree
or NestedTree
, both options require a dataSource
, and iterate through items in an array using a *cdkTreeNodeDef
attribute. In our component, it looks like this:
<cdk-tree-node *cdkTreeNodeDef="let node" cdkTreeNodePadding
class="tree-node">
The problem with this, is that every time we use node
in our view, it loses its type information. If we ever update the TreeNode
class with new properties, we risk mistyping the property name.
To resolve this, create a simple pipe and call it AsTreeNode
. This pipe’s objective is to transform whatever it receives into a TreeNode
, which will give us our type information back:
export class AsTreeNodePipe implements PipeTransform {
transform(value: unknown, ...args: unknown[]) {
return value as TreeNode;
}
}
While it’s true that this will require a bit more boilerplate on our view (as we’ll have to type node | asTreeNode
instead of just node
), we’ll always know that we’re accessing valid properties on our object.
Using a flat tree in Angular
In order to use a flat tree, we first need to define a DataSource
. A DataSource
defines two methods: a connect
method and a disconnect
method.
This is a bit of a mental shift, but we can think of the connect
method as implementing what should happen when our tree first starts attempting to display data, while the disconnect
method is what happens when the component is unloaded.
However, it’s not as simple as just sending data into our tree on connection. Instead, we need to listen to events that have occurred in our tree and modify the data source appropriately.
It’s a fairly confusing proposition. Let’s think through it:
- The tree is loaded. The node array is empty, as it has just been loaded
- The tree connects to the data source. The data source runs the
connect
method - Within the connect method, the data source subscribes to the
TreeControl
that is managing the tree. It listens for when nodes have beenadded
orremoved
- The
handleTreeControl
function handles the change event-
handleTreeControl
iterates through eachadded
node, and callstoggleNode
withexpanded: true
-
handleTreeControl
iterates through eachremoved
node, and callstoggleNode
withexpanded: false
-
There are some confusing aspects and unfamiliar words used that may make this harder than it needs to be. We should decode those before continuing:
- Why are
added
andremoved
arrays? While we think as expanding and collapsing nodes as a singular operation, it’s technically possible for multiple nodes to be expanded or collapsed at the same time. You may want to implement a feature where multiple parent nodes are expanded or collapsed at the same time via anexpandAll
operation. Most of the time, you’ll only have one item inadded
orremoved
based on what nodes you’ve expanded or collapsed - Why is it called
added
andremoved
? This is particularly confusing because the nodes we are expanding or collapsing are already part of the array. They’re not being added or removed. Instead, it helps to think of these asexpanding
andcollapsing
With this in mind, we can start to create our DataSource
, which will drive our tree.
Creating the DataSource
for our flat tree
Let’s dive into the skeleton of our data source:
class FlatTreeDataSource implements DataSource<TreeNode> {
dataChange = new BehaviorSubject<TreeNode[]>([]);
constructor(private _treeControl: FlatTreeControl<TreeNode>, private _api: DataService) {
}
get data(): TreeNode[] {
return this.dataChange.value;
}
set data(value: TreeNode[]) {
this._treeControl.dataNodes = value;
this.dataChange.next(value);
}
connect(collectionViewer: CollectionViewer): Observable<TreeNode[]> {
this._treeControl.expansionModel.changed.subscribe(change => {
if (
(change as SelectionChange<TreeNode>).added ||
(change as SelectionChange<TreeNode>).removed
) {
this.handleTreeControl(change as SelectionChange<TreeNode>);
}
});
return this.dataChange;
}
disconnect(collectionViewer: CollectionViewer): void {
}
handleTreeControl(change: SelectionChange<TreeNode>) {
debugger;
if (change.added) {
change.added.forEach(node => this.toggleNode(node, true));
}
if (change.removed) {
change.removed
.slice()
.reverse()
.forEach(node => this.toggleNode(node, false));
}
}
}
We have our dataChange
observable, into which we will send new tree data. We also have a dependency on a FlatTreeControl<TreeNode>
so we can listen to when nodes are expanded or collapsed, as well as our handleTreeControl
function.
How does the handleTreeControl
function work? In my opinion, this is where the implementation of a flat tree falls short and gets very confusing very quickly:
async toggleNode(node: TreeNode, expand: boolean) {
// Retrieve the index of the node that is asking for expansion
const index = this.data.indexOf(node);
// Set loading to true (show loading indicator)
node.loading.set(true);
// If we are expanding the node...
if (expand) {
// Retrieve nodes from API
let children = await firstValueFrom(this._api.longDataGet(node.data.index, 10));
// Map them to our TreeNode type
let nodes = children.map(x => new TreeNode(node.level + 1, x));
// For the last node in our retrieved list, set the last node option of TreeOption.Last (to show the "Load more..." button)
nodes[nodes.length - 1].options.update(x => x.add(TreeOption.Last));
if (!children || index < 0) {
// If no children, or cannot find the node, no op
return;
}
// Remove existing "last" nodes from existing data
this.data.forEach(x => x.options.update(y => {
y.delete(TreeOption.Last);
return y;
}))
// Insert the newly retrieved data at the right index
this.data.splice(index + 1, 0, ...nodes);
} else {
// Otherwise, if the node is being collapsed, work out how many nodes are children and remove them from the array
let count = 0;
for (
let i = index + 1;
i < this.data.length && this.data[i].level > node.level;
i++, count++
) {
}
this.data.splice(index + 1, count);
}
// Notify the BehaviourSubject that the data has changed
this.dataChange.next(this.data);
// Set the loading flag back to false
node.loading.set(false);
}
As you can see, every time we want to add or remove nodes from a flat tree, we have to scan the node array for the node we want, and then either insert or remove nodes via splice
from the array. Even our Load more… button, which should be fairly simple, requires the same kind of muck-about:
async loadMore(node: TreeNode){
node.loading.set(true);
let moreNodes = await firstValueFrom(this._api.longDataGet(node.data.index, 10));
let treeNodes = moreNodes.map(x => new TreeNode(node.level, x));
this.data.splice(this.data.indexOf(node), 1, ...treeNodes);
this.dataChange.next(this.data);
node.loading.set(false);
}
Oof — points lost, flat tree. But we’ve come this far, so we can’t just throw it out and do something else. Let’s continue with designing our view for the flat tree.
Designing the flat tree view
To use our tree, we can use the cdk-tree
or mat-tree
with our datasource
specified. We also then use the cdk-tree-node
to specify a template for a given node.
Some other examples — including the official documentation on cdk-tree
or mat-tree
— use two cdk-tree-node
nodes. One is for when the node has children, and the other is for when the node doesn’t have children. There’s simply no need for this, and it will only cause code duplication and some level of confusion.
Instead, we can render every node the same way. Then, if a node has children, we add a button to expand the node. If it doesn’t, we just add an empty disabled button with cdkTreeNodePadding
to give the node appropriate spacing.
Finally, we show loading text if the request is in-flight, along with a button if a node is the last node in a specific request:
@if (loading()){
Loading initial data....
}
<cdk-tree [dataSource]="datasource" [treeControl]="treeControl">
<cdk-tree-node *cdkTreeNodeDef="let node" cdkTreeNodePadding
class="tree-node">
<div style="display: flex; flex-direction: row; align-items: center; justify-items: end">
@if (hasChild(node)){
<button mat-icon-button cdkTreeNodeToggle
[attr.aria-label]="'Toggle ' + node.name"
[style.visibility]="node.expandable ? 'visible' : 'hidden'">
<mat-icon class="mat-icon-rtl-mirror">
{{treeControl.isExpanded(node) ? 'expand_more' : 'chevron_right'}}
</mat-icon>
</button>
}
@else{
<button mat-icon-button disabled cdkTreeNodePadding></button>
}
{{(node | asTreeNode).data.text}}
@if ((node | asTreeNode).loading()){
Loading...
}
</div>
@if ((node | asTreeNode).options().has(TreeOption.Last)){
<button (click)="loadMore(node)">Load more...</button>
}
</cdk-tree-node>
</cdk-tree>
The result is this: Hey, our tree view works, and our data is loading from the server! Unfortunately, using the FlatTree
in this instance has introduced some problems into our code:
- Heavy reliance on indexes and magic numbers: Everything works as long as you are completely immaculate with your use of indexes and accumulators. The moment you count objects incorrectly, or your math's is off, nodes will get inserted at the wrong position and your entire tree will turn to gobbledygook
- Will scale poorly: A new entry in the DOM for each node is okay for a few nodes, but if you start adding hundreds or thousands of nodes, the browser will gobble up memory and begin to chug. Array operations on a a huge array of nodes will also take a long time
- Tight coupling between the component and the
DataSource
: TheFlatTreeDataSource
needs to know when the tree node is expanded or collapsed, so it requires a dependency onFlatTreeControl
. In an ideal world, our data source should only be responsible for sourcing the data, and should not be aware or when a tree node is being collapsed or expanded - Difficult to show hierarchical relationships between data: Because each node exists within a flat array, it’s not easy to show dotted lines between nodes to indicate their relationship
For these reasons, I would not recommend the use of FlatTree
for anything but the simplest Angular tree view implementations. Before long, maintaining it could become difficult.
Exploring a better choice: The nested tree
With a nested tree in Angular, you immediately benefit from the fact that — unlike the flat tree — you don’t have to engage in any kind of flattening to produce the tree. Instead, each expanded node is rendered into an outlet that is only rendered if the node is expanded.
The only difficulty in the nested tree comes from the fact that it’s recursive. Each node essentially renders itself, over and over again, as the node is expanded. But we benefit from some clarity and ease-of-understanding in this approach.
We’ll go into this with a new class to represent our nested node, the appropriately named NestedTreeNode
:
export class NestedTreeNode {
children = new BehaviorSubject<Array<NestedTreeNode>>([]);
expandable = signal(true);
loading = signal(false);
options = signal<Set<TreeOption>>(new Set<TreeOption>())
selected = signal(false);
constructor(public level: number, public data: LongDataItem, public parent?: NestedTreeNode) {
}
}
It’s mostly the same, except:
- It has a new
children
property, which is aBehaviorSubject
and is initially empty - The constructor also accepts an optional
parent
argument
Because our nodes are now nested_,_ they each have children of their own. All we have to do is explain to Angular where to find the children and how to render them when it gets there.
The most important thing about this change is that children
is an Observable
, so its value can change over time. This dovetails nicely into our actual component setup, which looks like this:
export class NestedTreeComponent implements OnInit {
nestedTreeControl: NestedTreeControl<NestedTreeNode>;
nestedDataSource: MatTreeNestedDataSource<NestedTreeNode>;
private subscription?: Subscription;
constructor(private data: DataService) {
this.nestedTreeControl = new NestedTreeControl<NestedTreeNode>(x => x.children);
this.nestedDataSource = new MatTreeNestedDataSource<NestedTreeNode>();
}
}
Two lines, two big changes. Let’s break them down.
The nested tree control
Our tree control is now of type NestedTreeControl
, and the parameter is telling the tree control where it can find the children. Now I know you’re a busy developer, and according to my article-writer, you’ve already spent twelve minutes of your life reading this article, but I need to draw a huge underline under this:
Do not ever use a flat array as the children
property. You must always use something that implements an Observable
. I recommend BehaviorSubject
.
If you use a flat array, and you ever want to expand a node, or add more children to a node, you will waste days of your life trying to figure out why nothing works. As far as you are concerned, getChildren
only ever accepts something that implements Observable
.
Okay, that’s a big warning — so why is it such a big deal?
Simply put, if the children of a node update for any reason — for example, if a node expands, or if more nodes are loaded in asynchronously, etc. — and you append to the array, Angular will not notice these new nodes in its change-detection cycle, and your application UI will not update.
This is bad because it’s not what you intend, but it’s also very hard to troubleshoot. Before long, you’ll probably try to run a change detection cycle yourself which will introduce even more problems.
If you tell Angular that, yes, your children can update (by using something that implements an Observable
) the sun will shine on you as everything works as it is supposed to.
The nested data source
In our initial example with the flat tree, it was the data source’s responsibility to respond to nodes expanding or collapsing. It did so by finding the node in an array, loading more nodes from the server, and then splicing those new nodes into the existing tree array.
This approach wasn’t optimal because it required us to keep juggling an index to figure out where our new nodes should be inserted. It also caused us to write code that would be hard to maintain. With the nested tree, we can just use a MatTreeNestedDataSource<T>
as our datasource. This is possible because our data source isn’t running amok trying to find array nodes by index and jam arrays into other arrays at weird spots.
The responsibility of expanding nodes (and loading more nodes from the server) comes to the component itself, which is a better place for it logically. We also avoid writing our own class that implements the DataSource
. Considering that the FlatTreeDataSource
in our last example was 114 lines long, that’s quite a lot of time and effort saved.
Nested tree initialization
This brings us to our initial tree setup, where we receive a list of nodes from the server and assign them as the nestedDataSource
's data property. We also subscribe to the expansionModel
, as we’re still interested in whether or not nodes are expanding or collapsing:
async ngOnInit() {
let rootNodes = await firstValueFrom(this.data.longDataGet(0, 10));
this.nestedDataSource.data = rootNodes.map(x => new NestedTreeNode(0, x));
this.subscription = this.nestedTreeControl.expansionModel.changed.subscribe(change => {
if (change.added || change.removed) {
this.handleTreeControl(change);
}
})
}
Our handleTreeControl
function remains essentially the same. Again, multiple nodes could be expanded or collapsed at the same time.
It’s not necessarily a singular operation, hence the need to iterate through every node that has requested to be expanded or collapsed. added
and removed
are confusing words here, and it’s more appropriate to think of them as expanded
or collapsed
:
private handleTreeControl(change: SelectionChange<NestedTreeNode>) {
if (change.added) {
change.added.forEach(x => this.toggleNode(x, true));
}
if (change.removed) {
change.removed.slice().reverse().forEach(x => this.toggleNode(x, false));
}
}
The real payoff in the nested tree occurs in the toggleNode
function:
private async toggleNode(node: NestedTreeNode, expand: boolean) {
// If the node is asking to be expanded...
if (expand) {
// And the node hasn't already had its children loaded...
if (node.children.value.length == 0) {
// Set the loading indicator to true
node.loading.set(true);
// Retrieve the new nodes from the server
let children = await firstValueFrom(this.data.longDataGet(node.data.index, 10));
// Convert them to our NestedTreeNode
let nodes = children.map((x, index) => new NestedTreeNode(node.level + 1, x, node));
// Set the last node on the set to have the "last node" property, so the "load more" button is shown
nodes[nodes.length - 1].options.update(x => x.add(TreeOption.Last));
// Send the updated nodes into the BehaviourSubject
node.children.next(nodes);
// Set the loading indicator to false
node.loading.set(false);
}
}
}
Amazing! Children are loaded into the tree without so much as having to play around with indexes. This is a huge improvement to readability and intuitiveness over the flat tree.
Our loadMore
function benefits from these improvements also:
async loadMore(node: NestedTreeNode) {
// Set the loading indicator to true for the node
node.loading.set(true);
// Retrieve the next set of nodes from the server
let childData = await firstValueFrom(this.data.longDataGet(node.data.index! + 1, 10));
// Convert them to NestedTreeNode. Set the parent of the new nodes (not this node, this nodes parent)
let childNodes = childData.map(x => new NestedTreeNode(node.level, x, node.parent));
// Retrieve the existing children array
let existingChildren = node.parent?.children.value;
if (existingChildren) {
// Remove any "last node" option from existing nodes in this array
existingChildren.forEach(x => x.options.update(y => {
y.delete(TreeOption.Last);
return y;
}));
// Build the new array from the old nodes, and the new nodes we just received
let newChildArray = [...existingChildren, ...childNodes];
// Set the new data of the parent, and notify the tree that the nodes have updated
node.parent?.children.next(newChildArray);
}
// Set the loading indicator back to false
node.loading.set(false);
}
One last thing that we want to add to our nested tree is a function that lets the tree uniquely identify nodes within the tree. This will let it know which nodes require an update and which nodes can be left alone. In our case, that’s as simple as specifying a node level and an index, which will be unique to each node:
trackBy(_: number, node: NestedTreeNode){
return `${node.level}${node.data.index}`
}
With our component wired up, now let’s move on to working on the component view:
<cdk-tree [dataSource]="nestedDataSource" [treeControl]="nestedTreeControl"
style="display: flex; flex-direction: column" [trackBy]="trackBy">
<cdk-nested-tree-node *cdkTreeNodeDef="let node" class="example-tree-node">
<div style="flex-direction: row">
@if ((node | asTreeNode).expandable()) {
<button mat-icon-button cdkTreeNodeToggle>
<mat-icon>
@if (nestedTreeControl.isExpanded(node)) {
expand_more
} @else {
chevron_right
}
</mat-icon>
</button>
}
{{ (node | asTreeNode).data.text }}
@if ((node |asTreeNode).loading()) {
Loading...
}
</div>
@if ((node | asTreeNode).options().has(TreeOption.Last)){
<button (click)="loadMore(node)">Load more</button>
}
@if (nestedTreeControl.isExpanded(node)) {
<div style="display: flex; flex-direction: column">
<ng-container cdkTreeNodeOutlet>
</ng-container>
</div>
}
</cdk-nested-tree-node>
</cdk-tree>
We still have our cdk-tree
, but now we have a cdk-nested-tree-node
. If nodes are expandable, we render a button to undertake the expanding or collapsing, as well as to show a loading indicator and a Load more… button as required.
Finally, if a node is expanded, we use a ng-container
with a cdkTreeNodeOutlet
to render the node children. This causes the node children to render within the outlet via the cdk-nested-tree-node
. Every time a node is expanded, this continues over and over again, essentially recursing into itself to render each subsequent node within the view.
Making the nodes selectable
With the tree functional, now let’s make it so our nodes are selectable. Because each node appears in an array, and the array could be deeply nested within other arrays of nodes, it makes sense to make each node responsible for telling the data model if it has been selected or not.
Within our node template, adding a checkbox to the node is as simple as this:
<input type="checkbox" [checked]="(node | asTreeNode).selected()" (change)="handleNodeSelectionChange(node, $any($event.target).checked)" >
The only wrinkle is that the property that tells us whether a node has been checked or not exists in $event.target
, and the type information for that object isn’t fully recognized by TypeScript. So, we have to use $any
to strip $event.target
of its known type information before accessing that property.
The upside is that our handleNodeSelectionChange
function can be strongly typed, like so:
handleNodeSelectionChange(node: NestedTreeNode, checked: boolean) {
if (checked){
this.selectedNodes.update(x => {
x.push(node);
return x;
});
}
else{
this.selectedNodes.update(x => {
let nodeIndex = x.indexOf(node);
x.splice(nodeIndex, 1);
return x;
})
}
}
It’s simple — add the node when the checkbox is ticked, or remove it when it’s unticked. At this stage, our tree looks like this:
A practical Angular tree example: The Pokémon tree
It’s all well and good to have an expanding tree view that shows indexes. But what about more advanced cases, like where you have a tree that has children, but the children may retrieve nodes and data from several disparate API sources? Fortunately, that’s very possible to achieve with the Angular CDK/Material tree.
We’ll now create a tree that has a list of Pokémon. The tree view will display the data related to the Pokémon, but also have expandable nodes that relate to which movies, TV shows, or other media formats the Pokémon has appeared in. Our finished example will look like this: First thing to answer: what’s with the black borders? They demonstrate the outlet for the nodes, so we can easily see what nodes are rendered within a node outlet.
We start with the model for our Pokémon data, which is similar to the nested example, with the notable addition of a type
parameter:
export class PokemonTreeNode {
children = new BehaviorSubject<Array<PokemonTreeNode>>([]);
loading = signal(false);
constructor(public level: number,
public label: string,
public type: PokemonNodeType,
public expandable: boolean,
public parent?: PokemonTreeNode,
public data?: PokemonDetails | string | Array<string>,) {
}
}
export enum PokemonNodeType {
PokemonDetailsNode = 'Details',
InformationalNode = 'Informational',
GamesNode = 'Games',
TvShowsNode = 'TVShows',
BooksNode = 'Books',
PostersNode = 'Posters',
}
The type
parameter is there so we can specify what type of information this node is, as different nodes will have different conditions.
We want the topmost node with the Pokémon name to be expandable, as well as the nodes that relate to which movies the Pokémon has been in. However, we don’t want the informational nodes to be expandable — the ones that give us data on the Pokémon such as their height, weight, etc.
Our handleTreeControl
function is the same. However, our toggleNode
function has changed substantially to allow for nodes to be created based on the incoming data:
private async toggleNode(node: PokemonTreeNode, expand: boolean) {
// If the node already has children, then don't re-retrieve them. Cached nodes will be displayed instead.
if (node.children.value.length) return;
// If expansion has been requested...
if (expand) {
// Set the loading indicator true for the node
node.loading.set(true);
// Consider the type of node that is being expanded
switch (node.type) {
// If it's a details node (the node that has the Pokemon name in it)...
case PokemonNodeType.PokemonDetailsNode:
// Retreive the pokemon details from the server
let data = await firstValueFrom(this.data.pokemonDetailsByNameGet(node.label));
// Manually construct nodes to display Pokemon information
let treeNodes = [
new PokemonTreeNode(1, `Color: ${data.color}`, PokemonNodeType.PokemonDetailsNode, false, node),
new PokemonTreeNode(1, `Weight: ${data.weight}`, PokemonNodeType.PokemonDetailsNode, false, node),
new PokemonTreeNode(1, `Height: ${data.height}`, PokemonNodeType.PokemonDetailsNode, false, node),
new PokemonTreeNode(1, `Type: ${data.type}`, PokemonNodeType.PokemonDetailsNode, false, node),
new PokemonTreeNode(1, `Category: ${data.category}`, PokemonNodeType.PokemonDetailsNode, false, node),
// And the expandable nodes
new PokemonTreeNode(1, `Games`, PokemonNodeType.GamesNode, true, node),
new PokemonTreeNode(1, 'TV Shows', PokemonNodeType.TvShowsNode, true, node),
new PokemonTreeNode(1, 'Books', PokemonNodeType.BooksNode, true, node),
new PokemonTreeNode(1, 'Posters', PokemonNodeType.PostersNode, true, node),
];
// Tell the node children property that new values are available
node.children.next([...treeNodes]);
break;
case PokemonNodeType.GamesNode:
// If it's a games node that is being expanded, retrieve games for the pokemon and set them as the nodes children
// ...repeat the same thing for other types of node (Tv Shows/Books/etc.)
let games = await firstValueFrom(this.data.pokemonGamesByNameGet(node.parent?.label!, 0, 10));
node.children.next([...games.map(x => new PokemonTreeNode(2, x, PokemonNodeType.InformationalNode, false, node))]);
break;
case PokemonNodeType.TvShowsNode:
let shows = await firstValueFrom(this.data.pokemonTvshowsByNameGet(node.parent?.label!, 0, 10));
node.children.next([...shows.map(x => new PokemonTreeNode(2, x, PokemonNodeType.InformationalNode, false, node))]);
break;
case PokemonNodeType.BooksNode:
let books = await firstValueFrom(this.data.pokemonBooksByNameGet(node.parent?.label!, 0, 10));
node.children.next([...books.map(x => new PokemonTreeNode(2, x, PokemonNodeType.InformationalNode, false, node))]);
break;
case PokemonNodeType.PostersNode:
let posters = await firstValueFrom(this.data.pokemonPostersByNameGet(node.parent?.label!, 0, 10));
node.children.next([...posters.map(x => new PokemonTreeNode(2, x, PokemonNodeType.InformationalNode, false, node))]);
// debugger;
break;
default:
throw (`Unknown Node type ${node.type}`);
}
// Set the loading indicator back to false for the node
node.loading.set(false);
}
}
The main point here is how we manually build our tree nodes for display, and how we mark individual nodes as expandable or not. When our toggleNode
function receives different types of nodes to expand, it can choose the right API action to execute, and fill the tree view with the correct values.
Conclusion
Hopefully, from reading this guide, you’ve come to understand the tree in a lot more detail. I haven’t delved into every single possible visual representation that you could have in a tree, but I’ve hopefully helped you to understand how the foundations of the tree work.
The Angular tree view can be hard to get right, but once you understand it, it can be quite a powerful visual representation.
Don’t feel bad if you find it confusing or if you don’t get it right on the first go-round. When implemented correctly, the tree does follow a logical procession and is a high quality component, similar to what you may already be used to in the CDK or Material.
You can clone the project here. To run it locally, navigate into the server
directory, run npm i
and then node index.js
, and finally run ng serve
from the client
directory.
Top comments (0)