An Angular codebase calls cdr.detectChanges() after nearly every subscribe callback to make OnPush views update; what problems does that hide, and what would you replace it with?
answer
- state Angular cannot see
- subtree only, parents stale
- unbatched synchronous checks
- who owns the subscription
- let the state notify
basics
~20 sIt hides state Angular cannot track: each call is an unbatched synchronous subtree check that leaves ancestors stale and papers over leaking subscriptions. Replace it with signals, toSignal() or the async pipe, and markForCheck() only for unavoidable callbacks.
solid answer
~50 sThe pattern `subscribe(v => { this.items = v; this.cdr.detectChanges(); })` usually appears after a migration to `OnPush` (the default since v22) because assignments in callbacks stopped rendering. It works, but it hides several problems. The component's state is still invisible to Angular, so every author must remember the call. Each call runs a **synchronous** check of this subtree on every emission, bypassing the scheduler's batching; a chatty stream causes many checks per frame. It only checks **down**, so a parent showing the same data stays stale. And it often sits on subscriptions nobody tears down, so the check keeps running for a component that should be gone. The fix is to make state notify Angular itself: `toSignal()` or a `signal()` set in the callback, the `async` pipe, and `markForCheck()` only where a third-party callback leaves no other option.
code
ts · 19 linesimport { Component, computed, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { Order, OrderService } from './order.service';
@Component({
selector: 'app-open-orders',
template: `
<h3>{{ open().length }} open</h3>
@for (o of open(); track o.id) {
<div>{{ o.id }}: {{ o.total }}</div>
}
`,
})
export class OpenOrders {
private readonly orders = toSignal(inject(OrderService).list$(), {
initialValue: [] as Order[],
});
protected readonly open = computed(() => this.orders().filter(o => !o.closed));
}go deeper
Know that needing detectChanges() after every subscribe usually means the data should be a signal or go through the async pipe.
Explain why the call is synchronous and unbatched, why it leaves parents stale, and how toSignal() handles both subscription and teardown.
Diagnose the pattern as a symptom of non-reactive state after an OnPush or zoneless move, and plan a component-by-component refactor with a review rule.
Set the team policy: ChangeDetectorRef calls need justification, reactive state is the default, and migration effort is prioritised where streams are chattiest.
## What the code looks like ```ts ngOnInit() { this.orders.list$().subscribe(list => { this.orders = list; this.cdr.detectChanges(); }); this.filters.changes$.subscribe(f => { this.filter = f; this.cdr.detectChanges(); }); } ``` This shape spreads through a codebase when components become `OnPush`, which is every component without a `changeDetection` value since v22, or when an app goes zoneless (the default since v21). Assignments in callbacks stop showing on screen, someone discovers that `detectChanges()` "fixes it", and the pattern gets copied. ## What it hides ### 1. State that Angular cannot see The root cause is that `this.orders` is a plain field written from a callback. Angular has no way to know it changed. `detectChanges()` compensates by force, but the component still depends on every future author remembering the call on every new write path. Forget it once and that path silently does not render. ### 2. Unbatched synchronous work `detectChanges()` runs a check **immediately**, inside the callback: - a stream emitting 50 times a second causes 50 subtree checks a second, - several streams emitting in the same task each trigger their own check, - the scheduler, which would have merged these notifications into one pass, is bypassed. `markForCheck()` or a signal write would let all of them collapse into a single scheduled pass. ### 3. Parents and siblings left stale `detectChanges()` checks this view and its descendants only. If a parent's header shows `orders.length` from the same service, it does not update until something else checks it. The UI becomes inconsistent in ways that depend on timing, which makes bug reports hard to reproduce. ### 4. Leaking subscriptions The same code rarely tears its subscriptions down. After the component is destroyed, the callbacks keep firing, keep assigning fields on a dead instance and keep calling `detectChanges()`. The screen looks fine because the view is gone, so the leak goes unnoticed. ### 5. Masked timing problems Calling `detectChanges()` in lifecycle hooks is also a common way to make a dev-mode "expression changed after it was checked" error disappear without fixing why a value changed during a check. That error has its own proper fixes; forcing a check only hides the symptom. ## What to replace it with | Situation | Replacement | |---|---| | Stream consumed by the template | `toSignal(stream$)` or the `async` pipe | | Value written from a callback you control | a `signal()` set in the callback | | Derived values | `computed()` | | Third-party callback that writes a plain field | `markForCheck()` after the write | | Subtree you intentionally render on your own schedule | `detach()` plus `detectChanges()`, documented | A refactor of the example: ```ts protected readonly orders = toSignal(inject(OrderService).list$(), { initialValue: [] }); protected readonly filter = toSignal(inject(FilterService).changes$, { initialValue: defaultFilter }); protected readonly visible = computed(() => applyFilter(this.orders(), this.filter())); ``` `toSignal()` subscribes in the injection context and unsubscribes when the component is destroyed, the template reads `visible()`, and every change marks the view through the signal graph. ## Signs to look for in code review - `inject(ChangeDetectorRef)` in a component whose template only shows service data. - `detectChanges()` inside `subscribe()`, `setTimeout()` or `then()` callbacks. - Subscriptions without `takeUntilDestroyed()`, `toSignal()` or the `async` pipe next to those calls. - Comments such as "needed or the view doesn't update" with no explanation of why. ## How to roll it out 1. Search for `detectChanges(` outside tests and list the call sites. 2. Classify each: stream to template, callback write, or deliberate detached subtree. 3. Convert the first two groups to signals or the `async` pipe, one component at a time. 4. Keep the third group, with a comment explaining why. 5. Add a lint or review rule: new `detectChanges()` calls need a justification. ## Interview takeaway `detectChanges()` is a precise tool for detached views and synchronous DOM reads. Used as a general "make it render" button, it signals that the component's state is not reactive, and it trades one visible bug for several quieter ones.
- Is replacing every detectChanges() with markForCheck() enough?It fixes the batching and the stale-parent problems, because `markForCheck()` flags the path to the root and lets one pass handle everything. It does not fix the root cause: state is still a plain field written from callbacks, and the subscriptions still need teardown. Signals or the `async` pipe address all of it.
- When is calling detectChanges() in application code legitimate?For a view you have detached on purpose and refresh on your own schedule, and when code must read the updated DOM synchronously right after a state change, such as measuring an element before positioning an overlay. Both should be rare and commented.
saying these in an interview costs you the question
- detectChanges() after each subscribe is the standard OnPush pattern
- detectChanges() updates the parent components that show the same data
- Several detectChanges() calls in one task are merged into one check
- Once detectChanges() is called, unsubscribing no longer matters
- Swapping every detectChanges() for markForCheck() fixes the underlying design