angular/angular
Change detection
How Angular schedules and runs ticks. Has two modes that compose: zone-based scheduling (the historical default) and zoneless scheduling (now the recommended mode).
Mental model
A "tick" is one pass over the application's LView tree, calling each LView's update function. Bindings whose values changed since the last tick get re-applied to the DOM; bindings that didn't change are skipped via per-slot value comparison.
The scheduler decides when to tick. The Ivy runtime decides what to walk during a tick.
Zone-based scheduling
sequenceDiagram participant App as App code participant Zone as zone.js NgZone participant App2 as ApplicationRef participant Tree as LView tree App->>Zone: setTimeout / promise.then / event listener Zone->>Zone: track async task Note over Zone: When all tracked tasks finish ... Zone->>App2: NgZone.onMicrotaskEmpty App2->>App2: tick() App2->>Tree: walk dirty LViews Tree->>Tree: re-evaluate bindings
zone.js monkey-patches every async API. Each tracked task increments a counter; when the counter returns to zero, NgZone emits onMicrotaskEmpty, which the application listens for to schedule a tick. Without zone tracking, Angular wouldn't know when an asynchronous user action (a click handler that mutates state) was complete.
Implementation:
packages/core/src/zone/ng_zone.ts— theNgZonewrapper around the globalZone.packages/core/src/application/application_ref.ts—ApplicationRef.tick()and the legacy CD scheduler.packages/zone.js— the zone implementation itself.
Zoneless scheduling
The new model. Instead of relying on zone tracking, the scheduler runs only when something explicitly tells it to:
- A signal that an
LViewis reading changes (viaReactiveLViewConsumer). markForCheckis called on aChangeDetectorRef.- An event listener bound by
ɵɵlistenertriggers (the runtime hooks the listener and schedules a tick). - A router navigation completes.
- An async pipe emits a new value.
graph LR Signal["signal.set(...)"] --> Consumer["ReactiveLViewConsumer.notify()"] Consumer --> Mark["LView marked dirty"] Mark --> Schedule["Scheduler.notify()"] Schedule --> Microtask["Microtask queued"] Microtask --> Tick["ApplicationRef.tick()"] Tick --> Walk["Walk only dirty LViews"]
Implementation:
packages/core/src/change_detection/scheduling/— the scheduler abstractions.packages/core/src/change_detection/zoneless_scheduling.ts— the zoneless scheduler implementation.provideZonelessChangeDetection— the provider that opts an app into the new mode.
In zoneless mode NgZone is provided as a no-op so that legacy code calling NgZone.run still works.
What tick() does
ApplicationRef.tick() (packages/core/src/application/application_ref.ts) performs:
- Run pre-render lifecycle hooks (the
afterNextRendercallbacks for hooks scheduled to run after the next render, plus any pendingeffect()s). - Walk each root
LView. For each, if it's marked dirty (or itsChangeDetectionStrategyisDefault), execute the update pass. - Run post-render lifecycle hooks (
afterRender,afterNextRenderfor hooks scheduled for this render). - Process queued micro-tasks emitted during render (signal effects, internal scheduler work).
The walk is implemented per-LView in packages/core/src/render3/instructions/change_detection.ts.
OnPush
ChangeDetectionStrategy.OnPush is per-component. An OnPush component opts out of automatic dirty-marking from parents; the only ways its LView gets re-evaluated:
markForCheckwas called on itsChangeDetectorRef.- An
@Input()reference changed (the parent passes new values). - An event handler attached to its template fires.
- An async pipe in its template emits a new value.
In zoneless mode, all components effectively behave like OnPush — there's no global "everything is dirty by default" tick. Signals integrate with this model naturally: a signal change marks specifically the LViews that read it.
Lifecycle hooks
Lifecycle hooks fire in well-defined positions during a tick:
ngOnChanges— before the first update pass after an@Input()change.ngOnInit— once, before the first update pass.ngDoCheck— every update pass, before bindings.ngAfterContentInit/ngAfterContentChecked— after content children have been bound.ngAfterViewInit/ngAfterViewChecked— after view children have been bound.afterNextRender(fn)/afterRender(fn)— registered viapackages/core/src/render3/after_render; fired after the synchronous render pass.
The scheduler guarantees ordering. Hooks live in packages/core/src/render3/hooks.ts.
Effects
effect(() => …) (packages/core/primitives/signals/src/effect.ts + the application-aware wrapper in core/src/) registers a reactive callback that re-runs whenever any signal it reads changes. Effects schedule themselves via the same scheduler tick() uses, which means an effect runs as part of a tick, not in a separate pass.
Integration points
@angular/router— callsmarkForCheckafter navigation completes so router-outlet contents update.@angular/common(AsyncPipe) — callsmarkForCheckon emit.- Signal-based components — automatic; the runtime tracks the LView as a signal consumer.
@angular/forms— callsmarkForCheckwhen control state changes.
Where to start when modifying
- New scheduler trigger: extend
packages/core/src/change_detection/scheduling/. - New lifecycle hook: extend
packages/core/src/render3/hooks.tsand add a feature flag indefinition.tsso the runtime knows to call the new hook. - A new
provide…ChangeDetectionknob: define anEnvironmentProvidersfactory incore/src/change_detection/and add a feature in the public API.
The internal design notes for the change-detection model are scattered through commit messages and the test files — there's no single design doc beyond this page and the source.
Built by Factory AutoWiki from public repository content. It is a generated preview for codebase exploration, not source-maintained documentation.