Introduction
In this blog post, I explained how to build a simple Pokemon Gallery using the new control flow in Angular 17. New control flow is a new feature in Angular 17 that introduces built-in blocks to show/hide elements conditionally, and render a list of elements in HTML templates. The purpose of the built-in blocks is to replace structure directives in most cases, and make writing HTML codes as intuitive as possible. Moreover, these blocks are built-in; therefore, Angular developers do not need to import anything to standalone components.
New Control Flow | Structure directive equivalence | Purpose |
@if, @else if and @else | NgIf, NgElse and NgTemplate | Show and hide component by condition |
@for, @empty | NgFor | Iterate an array of data with a fallback when array is empty |
@switch, @case and @default | NgSwitch, NgSwitchCase and NgSwitchDefault | Match a value against cases and a default case when none of them matches |
Use case of the demo
In this demo, the Pokemon gallery displays 300 Pokemons in 10 pages, or 30 Pokemons per page. The page applies flexbox layout to arrange Pokemon cards and each card consists of id, name, weight and height. When a user clicks on a pokemon name, the application navigates the user to the details page to display more physical attributes.
Define Routes
First, I defined routes to navigate to PokemonListComponent and PokemonComponent.
// app.routes.ts
export const routes: Routes = [
{
path: 'list',
loadComponent: () => import('./pokemons/pokemon-list/pokemon-list.component')
.then((m) => m.PokemonListComponent),
title: 'Pokemon List'
},
{
path: 'list/pokemon/:id',
loadComponent: () => import('./pokemons/pokemon/pokemon.component')
.then((m) => m.PokemonComponent),
title: 'Pokemon Details'
},
{
path: '',
pathMatch: 'full',
redirectTo: '/list?page=1',
},
{
path: '**',
redirectTo: '/list?page=1',
}
];
Second, I provided the routes to provideRouter function and enabled withComponentInputBinding feature.
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
provideRouter(routes, withComponentInputBinding())
]
};
// main.ts
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));
Display pokemon list in PokemonListComponent
The application needs to display a list of pokemons on the browser; therefore, I created a PokemonListComponent.
pokemons is a signal that stores an array of pokemons.
// pokemon-list.component.ts
pokemons = toSignal(
toObservable(this.currentPage).pipe(switchMap(() => this.pokemonListService.getPokemons())),
{ initialValue: [] as DisplayPokemon[] }
);
In the inline template of the component, I used `@for` to iterate a list of pokemons and track keyword tracks each pokemon by its id. @for
has a major benefit over ngFor by making track mandatory. Track by can boost performance of a long list that consists of many items.
@for (pokemon of pokemons(); track pokemon.id) {
<app-pokemon-card [pokemon]="pokemon" />
}
Same as NgFor, implicit variables are variable in `@for`
@for (ability of abilities; track ability.name; let idx = $index) {
<div class="abilities">
<label for="ability_name">
<span>{{ idx + 1 }}. Name: </span><span id="ability_name" name="ability_name">{{ ability.name }}</span>
</label>
<label for="ability_isHidden">
<span>Effort: </span><span id="ability_isHidden" name="ability_isHidden">{{ ability.isHidden ? 'Yes' : 'No' }}</span>
</label>
</div>
} @empty {
<p>No Ability</p>
}
In the above loop, I assign the $index variable to idx in order to display row numbers. Other available implicit variables are $count
, $first
, $even
and $odd
.
Display Pokemon details in PokemonComponent
When a user clicks the hyperlink on a pokemon card, Angular routes the user to PokemonComponent to view the owner, abilities, statistics and physical attributes of the specific pokemon.
// pokemon.component.ts
@if (pokemonDetails$ | async; as pokemonDetails) {
<app-pokemon-physical [pokemonDetails]="pokemonDetails" />
<app-pokemon-statistics [statistics]="pokemonDetails.stats" />
<app-pokemon-abilities [abilities]="pokemonDetails.abilities" />
}
`pokemonDetails$` is an Observable of pokemon, `@if` resolves the Observable and assigns the data to pokemonDetails variable. Then pokemonDetail is passed as the input of PokemonPhysicalComponent, PokemonStatisticsComponent and PokemonAbilitiesComponent respectively.
// pokemon-statistics.component.ts
@for (stat of statistics; track stat.name) {
<div class="stats">
<label for="stat_name">
<span>Name: </span><span id="stat_name" name="stat_name">{{ stat.name }}</span>
</label>
<label for="stat_effort">
<span>Effort: </span><span id="stat_effort" name="stat_effort">{{ stat.effort }}</span>
</label>
<label for="stat_baseStat">
<span>Base Stat: </span><span id="stat_baseStat" name="stat_baseStat">{{ stat.baseStat }}</span>
</label>
</div>
} @empty {
<p>No statistics</p>
}
export class PokemonStatisticsComponent {
@Input({ required: true })
statistics!: Statistics[];
}
When the statistics array is non-empty, @for
block iterates it to display the information of each element. Otherwise, @empty
block displays “No Statistics” text. track stat.name indicates that items are tracked by name because name is unique for each Pokemon
Similarly, the @for/@empty block displays the special abilities of a Pokemon. Pokemon does not have duplicated abilities; therefore, it is a unique key for each ability row. When Pokemon has zero ability, then the empty block displays “No Ability” text.
// pokemon-abilities.component.ts
@for (ability of abilities; track ability.name) {
<div class="abilities">
<label for="ability_name">
<span>Name: </span><span id="ability_name" name="ability_name">{{ ability.name }}</span>
</label>
<label for="ability_isHidden">
<span>Effort: </span><span id="ability_isHidden" name="ability_isHidden">{{ ability.isHidden ? 'Yes' : 'No' }}</span>
</label>
</div>
} @empty {
<p>No Ability</p>
}
export class PokemonAbilitiesComponent {
@Input({ required: true })
abilities!: Ability[];
}
Finally, I used @switch
to show the owner of a few notable Pokemons. Pikachu, Stayyu, Steelix and Meowth are well-known in the series and their owners are either the series protagonists or antagonists. When a well-known pokemon type matches a switch case, the affiliation custom pipe displays its owner. When the pokemon has an unknown type, the unknown case displays “Your team is unknown” text. @default
should never occur because the unknown case satisfies all the pokemons that play minor roles in the series.
// affiliation.pipe.ts
@Pipe({
name: 'affiliation',
standalone: true
})
export class AffiliationPipe implements PipeTransform {
transform(name: string, team: string): string {
return `${name} is in Team ${team}.`;
}
}
export type PokemonAffiliation = {
type: 'pikachu',
owner: 'Ash',
} | {
type: 'meowth',
owner: 'Rocket',
} | {
type: 'staryu',
owner: 'Misty',
} | {
type: 'steelix',
owner: 'Brock',
} | {
type: 'unknown',
warningMessage: 'Your team is unknown',
}
When the value of affiliation.type is pikachu, meowth, staryu or steelix, PokemonAffiliation is narrowed to owner property. I provide affiliation.owner to the custom pipe to render. When the type property is unknown, PokemonAffiliation is narrowed to warningMessage property and the result is ‘Your team is unknown’. It is feasible because @switch
is capable of type narrowing in HTML templates.
// pokemon-affiliation.component.ts
@switch (affiliation.type) {
@case ('pikachu') {
<p>{{ affiliation.type | affiliation:affiliation.owner }}</p>
} @case ('meowth') {
<p>{{ affiliation.type | affiliation:affiliation.owner }}</p>
} @case ('staryu') {
<p>{{ affiliation.type | affiliation:affiliation.owner }}</p>
} @case ('steelix') {
<p>{{ affiliation.type | affiliation:affiliation.owner }}</p>
} @case ('unknown') {
<p>{{ affiliation.warningMessage }}</p>
} @default {
<p>This should not appear</p>
}
}
export class PokemonAffliationComponent {
@Input({ required: true })
affiliation!: PokemonAffiliation;
}
That is it. I created a simple Pokemon gallery using the new control to display pokemons. The new control flow is more intuitive to use than the structure directives. Moreover, the syntax is also easier to learn and memorize than their counterparts. NgIf, ngSwitch and ngFor do not go away completely but I foresee the new control flow will be seen more frequently in HTML templates than them in Angular 17 and onwards.
This is the end of the blog post and I hope you like the content and continue to follow my learning experience in Angular and other technologies.
Resources:
- Github Repo: https://github.com/railsstudent/ng-new-control-flow-demo
- Github Page: https://railsstudent.github.io/ng-new-control-flow-demo/list?page=1
- Angular New Control Flow: https://angular-dev-site.web.app/guide/templates/control-flow
- Angular Team Updates: https://www.youtube.com/watch?v=QrEH53tSUf0&t=1684s