After an Angular 22 upgrade, your team wants to delete the ChangeDetectionStrategy.Eager lines ng update added; what must you audit in each component first?
answer
- what the migration inserted
- state changed outside Angular's marks
- mutation and direct input writes
- which end of the tree first
basics
~20 sAudit every way template data changes without marking the view: fields set in subscriptions, timers or promises, mutated input objects, inputs written through @ViewChild, and mutable service state. Convert those to signals or immutable updates, starting with leaf components.
solid answer
~40 sThe v22 `ng update` migration added `changeDetection: ChangeDetectionStrategy.Eager` to components without a strategy and rewrote `Default` to `Eager`, so behaviour did not change. Deleting the line makes a component `OnPush`, refreshed only after a new input reference, a bound event, `markForCheck()` or a template signal change. So look for everything else: plain fields assigned in `subscribe()`, `setTimeout` or promise callbacks; in-place mutation of input objects and arrays; inputs written through a `@ViewChild` reference; templates reading mutable service fields; and listeners added with `addEventListener`. Move that state into signals or immutable updates. Convert leaf components first, because a skipped `OnPush` parent also skips its `Eager` children.
code
ts · 24 linesimport {Component, Injectable, inject, signal} from '@angular/core';
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
import {Subject} from 'rxjs';
@Injectable({providedIn: 'root'})
export class OrderFeed {
readonly statusChanges = new Subject<string>();
}
@Component({
selector: 'app-order-status',
// Removed: changeDetection: ChangeDetectionStrategy.Eager (added by ng update).
template: `<p>Status: {{ status() }}</p>`,
})
export class OrderStatus {
// Was `status = 'pending'` assigned in subscribe(): invisible under OnPush.
protected readonly status = signal('pending');
constructor() {
inject(OrderFeed)
.statusChanges.pipe(takeUntilDestroyed())
.subscribe((s) => this.status.set(s));
}
}go deeper
Know that ng update added Eager to keep old behaviour, and that deleting it makes the component OnPush.
List the patterns that mark nothing under OnPush: async field writes, input mutation, @ViewChild input writes, unwrapped listeners.
Plan the conversion bottom-up, exercise every update path in tests, and replace the findings with signals or immutable updates instead of markForCheck() patches.
Decide which components stay Eager as documented exceptions and how to sequence the audit across teams against its payoff in skipped subtrees.
## What the upgrade did Angular 22 changed what an omitted `changeDetection` means: such components are now `OnPush`. So that upgrading does not silently change behaviour, `ng update` runs a migration, `change-detection-eager`: 1. It adds `changeDetection: ChangeDetectionStrategy.Eager` (and the import) to each `@Component` whose metadata object has no `changeDetection` property. 2. It rewrites `ChangeDetectionStrategy.Default`, now a deprecated alias, to `ChangeDetectionStrategy.Eager`. 3. It leaves components that already declared `OnPush` untouched. After the upgrade the app behaves exactly as before. Deleting an `Eager` line is a behaviour change: the component becomes `OnPush` and is refreshed only when something marks it. ## What removing the line changes An `Eager` component is refreshed whenever a pass reaches it, which silently covers any state change that did not go through Angular's marking paths. Under `OnPush` the view is marked only by a new input reference from a template binding, an Angular-bound event in it or a descendant, `markForCheck()`, or a changed signal read in its template. The audit is a search for every **other** way the template's data changes. ## The audit checklist | Pattern in the component | Why it breaks under OnPush | Replacement | |---|---|---| | Plain field assigned in `subscribe()`, `setTimeout`, a promise or a third-party callback | nothing marks the view | hold the value in a signal and `set()` it | | In-place mutation of an input object or array (`push`, property assignment) | same reference, so the binding reports no change | immutable updates in the owner | | Input assigned through a `@ViewChild` or `@ContentChild` reference | a direct write bypasses the binding | bind it in the template, or `ComponentRef.setInput()` for dynamic components | | Template reads mutable service fields or getters over non-signal state | the service changes without marking any view | expose signals from the service | | A listener added with `addEventListener` or a library's own event API | Angular did not wrap it, so it marks nothing | a template or host binding, or a signal written from the callback | In a zone-based app, zone.js ran a pass after those callbacks and the `Eager` view picked the change up. That is why these patterns seemed to work. ## Convert bottom-up - **Start with leaf components.** An `OnPush` parent that a pass skips also skips its `Eager` children, so converting a parent first can break children you have not audited yet. - **Move up one level at a time**, re-running the component's tests after each change so every update path is exercised: input changes, user events, and async data arriving. - **Watch for components that are only correct because an ancestor is Eager.** Converting that ancestor is the moment they fail. ## A worked audit of one component Take `OrderStatus`, which shows a status string received from a feed service. 1. **List every template binding.** Here, one interpolation of `status`. 2. **Find every write to the values behind them.** `status` is assigned inside a `subscribe()` callback. 3. **Classify each write.** A subscription callback is not one of the four triggers, so under `OnPush` the text would stop updating. 4. **Fix the write, not the view.** Turn `status` into a signal and call `set()` in the callback, as in the example. 5. **Delete the `Eager` line and run the tests** that cover a status arriving from the feed. 6. **Only then move to its parent** and repeat the same steps there, since the parent's skipped passes now also decide when any remaining `Eager` children are refreshed. ## When to keep Eager - A component wrapping a third-party widget that mutates its own state and cannot be adapted cheaply. - Legacy code scheduled for rewrite, where the audit costs more than the remaining lifetime. - Keep these as explicit, commented exceptions rather than the default, so the next reader knows the component depends on always-check behaviour. ## The payoff Each converted component states its update triggers in its own code (inputs, events, signals), and whole subtrees can be skipped on passes that did not touch them. The audit is also a data-flow cleanup: most findings become signals or immutable updates, which the rest of the codebase benefits from.
- Why can converting a parent to OnPush before its children break a child that you never touched?An Eager child is refreshed only when its parent is refreshed, or when the child itself is marked. If the parent becomes OnPush and a pass skips it, an Eager child that relied on being checked on every pass stops updating even though its own code did not change.
- What does the v22 migration do with a component that already declares ChangeDetectionStrategy.OnPush?Nothing. It only adds `Eager` where the `changeDetection` property is missing and rewrites an explicit `ChangeDetectionStrategy.Default` to `Eager`. Components that already chose `OnPush` keep their setting.
saying these in an interview costs you the question
- The v22 migration already converted components to OnPush, so the Eager lines are dead code.
- If the app compiles after removing Eager, the component still behaves the same.
- Converting the root component first is safest, because children inherit its checks.
- A markForCheck() call after every assignment is a sound way to finish the audit.
- Components that use only template event handlers need a full rewrite before OnPush.