Introduction
Angular's change detection runs whenever any async event occurs. If your component's template modifies state during binding evaluation, change detection triggers repeatedly, causing performance issues or infinite loops detected by Angular.
Symptoms
ExpressionChangedAfterItHasBeenCheckedError:
ERROR Error: ExpressionChangedAfterItHasBeenCheckedError:
Expression has changed after it was checked.
Previous value: 'false'. Current value: 'true'.Infinite loop:
ERROR RangeError: Maximum call stack size exceeded
at Component._updateView (angular.js:123)Performance warning:
```bash $ ng serve
WARNING: Change detection running more than 100 times per second Component: AppComponent This indicates a change detection loop ```
Common Causes
- 1.Getter returns new value - Getter function creates new object each call
- 2.Template modifies state - Binding expression changes component property
- 3.Event handler in template - Function call triggers state change during binding
- 4.Pipe impure - Pipe returns different output for same input
- 5.ViewChild/ContentChild timing - Accessing before initialization complete
- 6.Observable subscription in template - Subscribe triggers state change
- 7. ngOnChanges modifies input - Changing input property during lifecycle
Step-by-Step Fix
Step 1: Identify the Loop Source
```typescript // Enable change detection profiling // In app.module.ts import { enableDebugTools } from '@angular/platform-browser';
@NgModule({ // ... }) export class AppModule { constructor(private appRef: ApplicationRef) { // Log change detection cycles this.appRef.tick = function() { console.log('Change detection tick'); return ApplicationRef.prototype.tick.apply(this, arguments); }; } }
// Or use Angular DevTools // npm install @angular-devtools // chrome://extensions -> Angular DevTools // Shows component tree and change detection cycles ```
Step 2: Check Template for Side Effects
typescript
// BAD: Template expression modifies state
@Component({
template:
<div>{{ incrementCounter() }}</div> <!-- Changes counter each render -->
<div>{{ counter = counter + 1 }}</div> <!-- Direct assignment -->
<button (click)="toggle()">Toggle</button>
{{ status }} <!-- OK if just reading -->
`
})
export class AppComponent {
counter = 0;
// BAD: Modifies state during binding incrementCounter(): number { this.counter++; // Side effect! return this.counter; } }
// FIX: Use pure computation, move state change to event handler
@Component({
template:
<div>{{ counter }}</div>
<button (click)="increment()">Increment</button>
})
export class AppComponent {
counter = 0;
increment(): void { this.counter++; // Only modify on user action } } ```
Step 3: Fix Impure Getters
typescript
// BAD: Getter creates new object each call
@Component({
template:
<div>{{ items.length }}</div>
<div>{{ filteredItems.length }}</div> <!-- Creates new array each check -->
`
})
export class AppComponent {
items = [1, 2, 3, 4, 5];
// BAD: Returns new array every time get filteredItems(): number[] { return this.items.filter(x => x > 2); // New array each call! } }
// FIX: Cache the result or use pure pipe
@Component({
template:
<div>{{ items.length }}</div>
<div>{{ items | filterPipe:'>2' }}</div>
})
export class AppComponent {
items = [1, 2, 3, 4, 5];
filteredItems: number[] = []; // Cached
ngOnInit() { this.filteredItems = this.items.filter(x => x > 2); }
// Update cached array when items change updateItems() { this.items.push(6); this.filteredItems = this.items.filter(x => x > 2); } }
// Or use pure pipe @Pipe({ name: 'filterPipe', pure: true // Only re-evaluate when input changes }) export class FilterPipe implements PipeTransform { transform(items: number[], condition: string): number[] { return items.filter(x => x > parseInt(condition)); } } ```
Step 4: Fix Observable Subscriptions
typescript
// BAD: Subscribe in template creates side effect
@Component({
template:
<div>{{ (data$ | async).length }}</div>
<div>{{ subscribeAndReturn() }}</div> <!-- BAD -->
`
})
export class AppComponent {
data$ = this.http.get('/api/data');
// BAD: Subscribes during binding, may trigger change detection subscribeAndReturn(): any { this.data$.subscribe(data => { this.localData = data; // Side effect during binding! }); return this.localData; } }
// FIX: Use async pipe or subscribe in ngOnInit
@Component({
template:
<div>{{ (data$ | async)?.length }}</div>
<div>{{ localData?.length }}</div>
})
export class AppComponent implements OnInit {
data$ = this.http.get('/api/data');
localData: any;
ngOnInit() { this.data$.subscribe(data => { this.localData = data; // Subscribe outside binding }); } } ```
Step 5: Fix ViewChild/ContentChild Access
typescript
// BAD: Access ViewChild before it's ready
@Component({
template:
<child #childRef></child>
{{ childRef.value }} <!-- Access during first render -->
`
})
export class AppComponent implements AfterViewInit {
@ViewChild('childRef') child: ChildComponent;
// BAD: Called before view initialized ngAfterViewInit() { // Changing ViewChild value here triggers error this.child.value = 'new value'; } }
// FIX: Use separate property or defer change
@Component({
template:
<child #childRef></child>
{{ childValue }}
})
export class AppComponent implements AfterViewInit {
@ViewChild('childRef') child: ChildComponent;
childValue = '';
ngAfterViewInit() { // Use setTimeout to defer change setTimeout(() => { this.childValue = this.child.value; }); } } ```
Step 6: Fix ngOnChanges Input Modification
typescript
// BAD: Modifying input property in ngOnChanges
@Component({
template: {{ processedData }}`
})
export class ChildComponent implements OnChanges {
@Input() data: any;
processedData: any;
ngOnChanges(changes: SimpleChanges) { // BAD: Modifying @Input property triggers change detection this.data = this.processData(this.data); // Side effect! this.processedData = this.data; } }
// FIX: Use separate property for processed data
@Component({
template: {{ processedData }}
})
export class ChildComponent implements OnChanges {
@Input() data: any;
processedData: any;
ngOnChanges(changes: SimpleChanges) { if (changes.data) { // Don't modify input, use separate property this.processedData = this.processData(changes.data.currentValue); } }
processData(data: any): any { // Pure function, no side effects return { ...data, processed: true }; } } ```
Step 7: Use OnPush Change Detection
typescript
// Reduce change detection cycles with OnPush
@Component({
selector: 'app-child',
template: {{ data }}`,
changeDetection: ChangeDetectionStrategy.OnPush // Only check on input change
})
export class ChildComponent {
@Input() data: string;
}
// OnPush only runs change detection when: // 1. @Input reference changes (not just value) // 2. Event from component or children // 3. Async pipe receives new value // 4. Manual markForCheck() call
// For manual trigger @Component({ changeDetection: ChangeDetectionStrategy.OnPush }) export class AppComponent { constructor(private cdr: ChangeDetectorRef) {}
updateData() { this.data = 'new value'; this.cdr.markForCheck(); // Trigger change detection } } ```
Step 8: Debug with ng.profiler
```typescript // Enable profiler in development // In main.ts import { enableProdMode } from '@angular/core';
if (!isProd) { // Keep profiler enabled in dev enableDebugTools(); } else { enableProdMode(); }
// In browser console: ng.profiler.timeChangeDetection();
// Output shows: // Change detection cycles per second // Time per cycle // Components checked
// High numbers indicate loop ```
Step 9: Use NgZone for External Events
typescript
// Run external events outside Angular zone
@Component({
template: {{ position.x }}`
})
export class AppComponent {
position = { x: 0, y: 0 };
constructor(private ngZone: NgZone) { // BAD: setInterval inside zone triggers change detection setInterval(() => { this.position.x++; // Triggers change detection every tick }, 100);
// FIX: Run outside zone this.ngZone.runOutsideAngular(() => { setInterval(() => { this.position.x++; // Manually trigger when needed this.ngZone.run(() => { this.cdr.detectChanges(); }); }, 100); }); } } ```
Step 10: Detect with Strict Template Checking
```bash # Enable strict template type checking # In tsconfig.json { "angularCompilerOptions": { "strictTemplates": true, "strictInputTypes": true, "strictOutputTypes": true, "strictDomEventTypes": true } }
# This catches many binding issues at compile time
# Run with full checks ng build --strictTemplates
# Issues detected: # - Function calls in bindings # - Impure pipes # - Type mismatches ```
Common Change Detection Issues
| Issue | Detection | Fix |
|---|---|---|
| Getter returns new object | Every cycle | Cache result or use pure pipe |
| Function in template | Every cycle | Use property, update on event |
| ViewChild before init | First render | Use ngAfterViewInit + setTimeout |
| ngOnChanges modifies input | Input change | Use separate property |
| Observable subscribe in template | Every cycle | Use async pipe or ngOnInit |
Verification
```typescript // After fixing change detection issues
// 1. Run profiler // In browser console: ng.profiler.timeChangeDetection({ record: true });
// Should show: ~1-5 ms per cycle, no excessive cycles
// 2. Check for ExpressionChangedAfterItHasBeenCheckedError // Run in development mode (detects errors) ng serve
// Should not see the error in console
// 3. Monitor performance // Use Chrome DevTools Performance tab // Record user interactions // Check "Script" time - should not spike on change detection
// 4. Test component // Trigger events, update data // Verify UI updates correctly without performance issues
// 5. Check production bundle ng build --prod // Should not see change detection warnings ```
Prevention
To prevent Angular change detection loop issues from recurring, implement these proactive measures:
1. Enable Strict Template Checking
// tsconfig.json
{
"angularCompilerOptions": {
"strictTemplates": true,
"strictInputTypes": true,
"strictOutputTypes": true
}
}2. Use OnPush Change Detection
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `...`
})
export class OptimizedComponent {
// Only triggers change detection when inputs change
// Or when async pipe receives new value
}3. Avoid Functions in Templates
typescript
// BAD: Function called every change detection cycle
@Component({
template: <div>{{ getName() }}</div>`
})
export class BadComponent {
getName() { return this.user.name; }
}
// GOOD: Use property
@Component({
template: <div>{{ userName }}</div>
})
export class GoodComponent implements OnInit {
userName: string;
ngOnInit() {
this.userName = this.user.name;
}
}
```
Best Practices Checklist
- [ ] Enable strict template checking
- [ ] Use OnPush change detection
- [ ] Avoid functions in templates
- [ ] Use pure pipes for transformations
- [ ] Cache computed values
- [ ] Run change detection profiler regularly
Related Issues
- [Fix Angular ExpressionChangedAfterChecked](/articles/fix-angular-expressionchangedafterchecked)
- [Fix Angular Dependency Injection Error](/articles/fix-angular-dependency-injection-error)
- [Fix Angular Memory Leak Subscription](/articles/fix-angular-memory-leak-subscription)
Related Articles
- [Technical troubleshooting: Fix Browser Back Button State Lost Spa Navigation ](browser-back-button-state-lost-spa-navigation)
- [Fix Cors Fetch Localhost Development Blocked Issue in Frontend](cors-fetch-localhost-development-blocked)
- [Fix Csp Violation Blocked Inline Script Style Issue in Frontend](csp-violation-blocked-inline-script-style)
- [Fix Angular Dependency Injection Error](fix-angular-dependency-injection-error)
- [Fix Angular ExpressionChangedAfterItHasBeenCheckedError](fix-angular-expressionchangedafterchecked)
<script type="application/ld+json"> { "@context": "https://schema.org", "@type": "TechArticle", "headline": "Fix Angular Change Detection Loop", "description": "Troubleshoot Angular change detection loops. Identify template side effects, getter impurity, and async patterns.", "url": "https://www.fixwikihub.com/fix-angular-change-detection-loop", "publisher": { "@type": "Organization", "name": "FixWikiHub", "url": "https://www.fixwikihub.com" }, "author": { "@type": "Person", "name": "FixWikiHub Editorial Team" }, "datePublished": "2026-04-04T02:19:47.959Z", "dateModified": "2026-04-04T02:19:47.959Z" } </script>