Understanding change detection in Angular
Change detection is the process Angular uses to decide which parts of the DOM
to update after something in your application changes. Most of the time it is
invisible. RxAngular's template and render-strategy tooling exists because, for
interaction-rich and data-heavy UIs, the default process does far more work than
the change requires: it over-renders. This page explains why that happens and
where signals, OnPush, and RxAngular's fine-grained rendering fit.
The idea
Angular's change detection is organized as a tree of components. When a change happens, Angular walks that tree and, for each component it visits, re-evaluates the component's template bindings and updates the DOM where a bound value differs. The unit of this work is a whole component template: the smallest thing Angular can re-check is one component's view, not an individual binding inside it.
The historically important detail is what triggered the walk and how much of the tree it covered.
- The default check is coarse. When change detection runs without
OnPush, Angular re-evaluates the component where the change was introduced and every component on the path down, marking a dirty path and re-checking each view on it. A single value change high in the tree can cause a large subtree to be re-evaluated, even though most of those components had nothing change. - The unit is the template, not the binding. Even a targeted
ChangeDetectorRef.detectChanges()re-evaluates the entire template of that component. For a component that renders a large list or an interactive surface, re-checking the whole template on every update is often more work than the frame budget allows, and it shows up as jank or a poor INP (Interaction to Next Paint) score.
The consequence is over-rendering: components get re-evaluated (and sometimes re-rendered) far more often than their own data changed. On fast hardware this is invisible; on lower-end devices, or in dynamic UIs with frequent updates, it is the difference between a smooth application and an unusable one.
Where the "async pipe is broken" story came from
A related, widely-repeated framing is that binding reactive sources with the
async pipe is inefficient, and that developers are pushed toward an
*ngIf/async workaround to read a stream's value in the template. The kernel of
truth is real: reading the same observable through multiple async pipes creates
redundant subscriptions, and, under the older Zone.js-driven model, an
emission marked the component and all its ancestors dirty, then waited for a
global ApplicationRef.tick() to re-check the tree top-to-bottom. That global,
brute-force re-check is the over-rendering described above, surfacing through the
pipe.
The modern correction: this is a change-detection problem, not an
AsyncPipe defect. AsyncPipe calls markForCheck on emission and is a valid
notifier even under zoneless change detection. RxAngular's rxLet, rxFor, and
rxIf earn their place by changing the granularity of the update (see below)
and adding a reactive context (suspense / error /
complete) that the plain pipe has no notion of.
How it works, and what modern Angular changed
Two shifts in modern Angular reshape this picture, and RxAngular is now positioned relative to them rather than as a workaround for the old model.
Zoneless is the default. As of v21, Angular runs change detection
zoneless by default and Zone.js is dropped from the default bundle. Instead of
Zone.js monkey-patching every event, timer, and promise to guess "something might
have changed, re-check everything," notification is now explicit: signals that are
read in a template register a dependency and schedule a check when they change;
markForCheck (called for you by AsyncPipe, rxLet, and friends) flags a view
as needing a check. Notification became push-based and targeted, where a change
announces itself to exactly the views that depend on it, rather than a global
Zone-driven sweep. The history of why Zone.js drove change detection and
what the residual zone tooling is for is covered in
Zoneless & how Zone.js affected change detection.
Signals and OnPush narrow the walk. OnPush tells Angular to skip a
component's subtree unless it is explicitly marked dirty (an input reference
changed, an event fired in it, or markForCheck was called). Signals go further:
a signal read in a template ties that specific binding to that specific signal, so
when the signal changes Angular knows which views depend on it. Together they
turn the default "re-check the dirty path" into "re-check only what depends on the
change." For local component state, signals are the default tool: reach for
signal() and computed() first; RxAngular's state layer complements them for
shared, derived, and async-heavy cases
(see Reactive state: global vs local).
Where RxAngular still moves the needle. Even with zoneless + signals + OnPush,
the smallest unit Angular checks is a component template. RxAngular's template
directives push the unit down to the EmbeddedView, the individual row or
branch a structural directive owns, so an update to one item in a large live list
re-renders that item, not the whole list's template. On top of that, render
strategies let that work be scheduled: sliced across frames and prioritized
against the frame budget, instead of run synchronously in one blocking pass. That
scheduling capability has no native Angular equivalent and is explained in
Concurrent scheduling & the frame budget.
The mental model for a modern app is layered: signals + OnPush decide
whether a view is checked; RxAngular's directives shrink the unit of the
check to the EmbeddedView; render strategies decide when that work runs.
Trade-offs / context
- Default change detection trades control for convenience. The old Zone-driven, re-check-everything model was convenient (you rarely had to think about when rendering happened) at the cost of doing far too much work. The modern zoneless/signals model reverses that trade: notification is explicit, so the framework does much less unrequested work, but you (and your reactive primitives) are now responsible for signalling change correctly.
- Reach for fine-grained rendering when the template unit is too coarse.
Signals and
OnPushare enough for most components. TheEmbeddedView-level directives and render strategies earn their complexity when a single template re-check is too expensive: large frequently-updating lists, virtualized viewports, and surfaces where you must stay within a frame budget. For a simple component, they are over-engineering. - Over-rendering is a measurement problem first. Whether the default check is "too much" depends on template size, update frequency, and target hardware, so the fix is a set of tools you apply where the profiler points, not a blanket replacement of Angular's rendering.
Referenced by
The following pages lean on this concept as their shared "why is this slow / why does Angular over-render" hub:
- CDK render-strategies overview
- CDK basic strategies
- CDK strategies (custom strategies)
- CDK concurrent strategies
- Template
rxFordirective - Template
rxIfdirective - Template
rxLetdirective - Template virtual scrolling
- Template push pipe
- Tutorial: Render a large live list without jank (T2)
- Tutorial: Loading / error / empty states the reactive way (T4)