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.
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.
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:
Let’s get into it.
The first thing we have to address with the tree is that there are two types of trees to use:
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.
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.
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.
pipe
to help with typesWhether 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.
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 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:
connect
methodTreeControl
that is managing the tree. It listens for when nodes have been added
or removed
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:
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 collapsedadded
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.
DataSource
for our flat treeLet’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.
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:
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 expandedFor 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.
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:
children
property, which is a BehaviorSubject
and is initially emptyparent
argumentBecause 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.
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.
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.
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.
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:
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.
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.
Debugging Angular applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Angular state and actions for all of your users in production, try LogRocket.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens on your site including network requests, JavaScript errors, and much more. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred.
The LogRocket NgRx plugin logs Angular state and actions to the LogRocket console, giving you context around what led to an error, and what state the application was in when an issue occurred.
Modernize how you debug your Angular apps — start monitoring for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBackdrop and background have similar meanings, as they both refer to the area behind something. The main difference is that […]
AI tools like IBM API Connect and Postbot can streamline writing and executing API tests and guard against AI hallucinations or other complications.
Explore DOM manipulation patterns in JavaScript, such as choosing the right querySelector, caching elements, improving event handling, and more.
`window.ai` integrates AI capabilities directly into the browser for more sophisticated client-side functionality without relying heavily on server-side processing.