Working with the Angular tree: Flat vs. nested trees and more

Working with the Angular tree: Flat vs. nested trees and more

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:

<cdktreenode *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 been added or removed

The handleTreeControl function handles the change event

handleTreeControl iterates through each added node, and calls toggleNode with expanded: true

handleTreeControl iterates through each removed node, and calls toggleNode with expanded: 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 and removed 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 an expandAll operation. Most of the time, you’ll only have one item in added or removed based on what nodes you’ve expanded or collapsed
Why is it called added and removed? 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 as expanding and collapsing

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….
}

<cdktree [dataSource]=datasource [treeControl]=treeControl>
<cdktreenode *cdkTreeNodeDef=let node cdkTreeNodePadding
class=tree-node>
<div style=display: flex; flex-direction: row; align-items: center; justify-items: end>
@if (hasChild(node)){
<button maticonbutton cdkTreeNodeToggle
[attr.arialabel]=‘Toggle ‘ + node.name
[style.visibility]=node.expandable ? ‘visible’ : ‘hidden’>
<maticon class=mat-icon-rtl-mirror>
{{treeControl.isExpanded(node) ? expand_more : chevron_right}}
</mat-icon>
</button>
}
@else{
<button maticonbutton 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: The FlatTreeDataSource needs to know when the tree node is expanded or collapsed, so it requires a dependency on FlatTreeControl. 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 a BehaviorSubject 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:

<cdktree [dataSource]=nestedDataSource [treeControl]=nestedTreeControl
style=display: flex; flex-direction: column [trackBy]=trackBy>
<cdknestedtreenode *cdkTreeNodeDef=let node class=example-tree-node>
<div style=flex-direction: row>
@if ((node | asTreeNode).expandable()) {
<button maticonbutton cdkTreeNodeToggle>
<maticon>
@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>
<ngcontainer 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.

Please follow and like us:
Pin Share