Template Function Calls: A Hidden Trap for Angular Routing
A colleague of mine recently hit a puzzling issue in our app. Adding RouterLinkActive to a link caused the entire application to stop rendering. Removing the directive made everything work again.
Rather than jumping straight to the fix, I built a challenge on AngularChallenges so readers can try diagnosing the root cause themselves. Once you've given it a shot, come back here to see how my approach compares with yours and what really went wrong.
For clarity, here's a minimal reproduction of the failure scenario:
interface MenuItem {
path: string;
name: string;
}
@Component({
selector: 'app-nav',
standalone: true,
imports: [RouterLink, NgFor, RouterLinkActive],
template: `
<ng-container *ngFor="let menu of menus">
<a
[routerLink]="menu.path"
[routerLinkActive]="isSelected"
>
{{ menu.name }}
</a>
</ng-container>
`,
})
export class NavigationComponent {
@Input() menus!: MenuItem[];
}
@Component({
standalone: true,
imports: [NavigationComponent, NgIf, AsyncPipe],
template: `
<ng-container *ngIf="info$ | async as info">
<ng-container *ngIf="info !== null; else noInfo">
<app-nav [menus]="getMenu(info)" />
</ng-container>
</ng-container>
<ng-template #noInfo>
<app-nav [menus]="getMenu('')" />
</ng-template>
`,
})
export class MainNavigationComponent {
private fakeBackend = inject(FakeServiceService);
readonly info$ = this.fakeBackend.getInfoFromBackend();
getMenu(prop: string) {
return [
{ path: '/foo', name: `Foo ${prop}` },
{ path: '/bar', name: `Bar ${prop}` },
];
}
}
MainNavigationComponent renders NavigationComponent and supplies a list of MenuItem objects derived from an HTTP response. After the request resolves, the getMenu function is invoked—with an empty string when no data is present, or with the returned info when available.
NavigationComponent loops through MenuItem, generating a link for each entry via RouterLink and RouterLinkActive.
At first glance, the code looks perfectly sound, yet putting RouterLinkActive on each link stops the UI from rendering without any console errors.
What could possibly be going on? 🤯
To get to the bottom of it, let's examine RouterLinkActive and the exact code responsible for the endless rendering cycle:
import { Directive } from '@angular/core';
@Directive({
selector: '[fake]',
standalone: true,
})
export class FakeRouterLinkActiveDirective {
constructor(private readonly cdr: ChangeDetectorRef) {
queueMicrotask(() => {
this.cdr.markForCheck();
});
}
}
Within RouterLinkActive, this.cdr.markForCheck() is called to flag the component as needing a check. But this call happens inside a separate micro task. When the current macro task finishes, Angular schedules another change detection pass in the following micro task.
Can you identify the problem with that knowledge in hand?
Because a fresh change detection cycle gets kicked off, Angular re-evaluates every binding, which triggers new function invocations. As a result, getMenu in MainNavigationComponent runs yet again and returns a brand-new MenuItems instance.
But the trouble doesn't stop there.
NavigationComponent goes through the array with NgFor. Given a different MenuItem instance coming in as an Input, NgFor rebuilds its collection from scratch. That means all DOM nodes inside the list get removed and then recreated. The recreation of RouterLinkActive then sets off another change detection round, creating a boundless loop.
A trackBy function on NgFor provides one escape route. This function picks a property on each item and verifies whether that property remains present in the new array. NgFor will only DESTROY or CREATE an element when the tracked property has disappeared or is newly introduced. Adding trackBy to our code eliminates the infinite re-rendering.
If you find yourself perpetually forgetting trackBy, you may want to look at this article for guidance.
That said, even with trackBy fixing the error, generating a fresh MenuItem instance on every change detection run remains poor practice.
One potential workaround is storing menuItem in a class property, but that pushes the code toward imperative style and potential spaghetti logic.
A far better route is to embrace a declarative mindset. Here's how to reshape the same logic more elegantly:
@Component({
standalone: true,
imports: [NavigationComponent, AsyncPipe],
template: ` <app-nav [menus]="(menus$ | async) ?? []" /> `,
})
export class MainNavigationComponent {
private fakeBackend = inject(FakeServiceService);
readonly menus$ = this.fakeBackend
.getInfoFromBackend()
.pipe(map((info) => this.getMenu(info ?? '')));
getMenu(prop: string) {
return [
{ path: '/foo', name: `Foo ${prop}` },
{ path: '/bar', name: `Bar ${prop}` },
];
}
}
Our menus$ property is now assigned at a single point and gets updated solely when getInfoFromBackend emits its result. The menu$ stream won't be re-evaluated during every change detection pass, and just one instance is created over the entire lifespan of MainNavigationComponent. The whole thing reads more cleanly, wouldn't you agree?
You've likely been told that invoking functions in templates is discouraged—and for the most part, that advice holds firm. A function call to reach deeply nested object properties might be an acceptable exception, but that's about it. Steer clear of function calls in your template bindings unless you're fully aware of what's happening and the potential side effects. When a function mutates data as you call it, alarm bells should go off. In most situations, a cleaner declarative alternative exists for what you're trying to achieve. Declarative programming takes some getting used to, but it's worth pursuing. Stick with it, and you'll find your code becomes more transparent and simpler—your teammates and future you will appreciate it.
My hope is this piece clarifies why template function calls can be dangerous.
Notes: Looking ahead, Angular's move to "signal" based reactivity will lower this risk. Since signals are memoized, you won't end up recreating new instances as often. 🔥
You can track me down on Twitter or Github. Feel free to reach out anytime if you have questions.
