← Back to list

Change Detection Without the Gotchas: Angular OnPush, Signals & Zoneless Explained

Angular change detection demystified: boost performance with OnPush, signals, and zoneless. Avoid pitfalls and migrate safely to Angular…

QuarkAndCode · 2025-10-02 17:35 · 1 claps · 12.5 min read paywalled
#angular #change-detection #onpush #signal #zoneless
Open on Medium ↗
Wiki topics: 🌐 · Web Development

Change Detection Without the Gotchas: Angular OnPush, Signals & Zoneless Explained

Angular change detection has always had one job: keep the screen in sync with application state. The confusing part was never the goal. The confusing part was knowing why a component updated, why another one did not, and which async event actually caused Angular to check the page.

Modern Angular is making that story much clearer.

Instead of relying on broad “something happened, maybe the UI changed” checks, Angular now favors a more explicit model built around OnPush, signals, and zoneless change detection. These three ideas are connected:

OnPush helps Angular skip work.

Signals tell Angular exactly where to read state.

Zoneless removes Zone.js from the center of the update loop and instead relies on clear Angular notifications.

The result is a change detection model that is easier to reason about, easier to optimize, and less likely to surprise you.

What Angular Change Detection Actually Does

Change detection is Angular’s process for checking whether values used in templates have changed and updating the DOM when needed. In older Angular applications, this process was often powered by Zone.js. Zone.js patches browser APIs such as timers, network requests, and event listeners so Angular can be notified after asynchronous work happens. Angular could then run change detection because application state might have changed. Angular’s own documentation describes Zone.js as a signaling mechanism that captures async operations such as setTimeout, network requests, and event listeners.

That model was convenient, but not always precise. A timer, animation frame, or third-party library callback might fire without changing anything in your app. Angular could still run change detection anyway. This is one reason Angular’s performance docs talk about “zone pollution”: unnecessary change detection caused by async tasks that do not actually update application state.

Modern Angular moves the focus from “something async happened” to “something Angular knows the template depends on changed.”

That is the key idea behind OnPush, signals, and zoneless Angular.

From Default to Eager: The Naming Shift

For years, Angular developers talked about two main change detection strategies:

ChangeDetectionStrategy.Default
ChangeDetectionStrategy.OnPush

In current Angular, the old Default strategy has been renamed to Eager. Default is now a deprecated alias for Eager. The new name is clearer because the strategy eagerly checks a component whenever the change-detection traversal reaches it.

So when you see this:

changeDetection: ChangeDetectionStrategy.Eager

read it as:

“Check this component whenever Angular reaches it during a normal change detection pass.”

That older eager model is forgiving, especially in Zone.js-based applications. You might mutate a class property or object, and the UI may still update because Angular checks broadly.

this.user.name = 'Ada';

In an eager, Zone.js-based app, this often works. But it works because Angular is doing a broad check, not because the code explicitly signaled the state change.

OnPush and zone-less Angular make that kind of accidental success less reliable. That is a good thing. It pushes the application toward clearer state updates.

OnPush: Angular Checks Less, Not Never

OnPush is often described as a performance setting, but it is better understood as a contract.

With OnPush, Angular can skip a component subtree unless it receives a reason to check it. In Angular v22, OnPush is the default change detection strategy for new applications and generated components; older projects may still contain components using the previous eager behavior or explicit strategy settings. Angular’s docs state that OnPush is the default since v22.

You can still write it explicitly, especially in older codebases:

import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-user-card',
  templateUrl: './user-card.html',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserCardComponent {}

OnPush does not mean Angular ignores the component forever. It means Angular checks the component when something relevant happens.

Angular runs change detection for an OnPush subtree when the subtree root receives new inputs from a template binding, when Angular handles an event in that subtree, or when the component is explicitly marked for check. Angular’s docs also note that input comparison uses ==, which means object inputs are still effectively compared by reference unless their value changes to a different object.

A useful mental model is:

OnPush components update when Angular gets a clear notification.

Those notifications usually come from template input bindings, event handlers, signals, AsyncPipe, or ChangeDetectorRef.markForCheck().

The Classic OnPush Gotcha: Mutating Objects

The most common OnPush bug is mutating an object or array while keeping the same reference.

// Risky with OnPush
this.user.name = 'Grace';

If user is passed into an OnPush component as an input, Angular may not check the child component because the object reference did not change. The same issue happens with arrays:

// Risky with OnPush
this.items.push(newItem);

Angular’s docs explicitly call this out: if an input receives a mutable object and you modify the object while preserving the same reference, Angular will not invoke change detection for the OnPush component because the previous and current values point to the same reference.

Use replacement instead:

this.user = {
  ...this.user,
  name: 'Grace',
};

And for arrays:

this.items = [...this.items, newItem];

This is not about following a fashionable rule of immutability. It is about making the change visible.

A new object or array reference tells Angular, “This input changed.” A mutation buried inside the same object does not.

Signals: State Angular Can Track Precisely

Signals are Angular’s reactive state primitive. A signal wraps a value and notifies consumers when that value changes. You read a signal by calling it like a function, which lets Angular track where the value is used.

import { signal } from '@angular/core';

const count = signal(0);

console.log(count()); // read

count.set(1); // replace

count.update(value => value + 1); // update from previous value

Signals can hold primitives, arrays, objects, or any other value. The important part is that updates go through the signal API.

Computed signals derive values from other signals:

import { computed, signal } from '@angular/core';

const count = signal(2);
const doubled = computed(() => count() * 2);

console.log(doubled()); // 4

Computed signals are lazy and memoized. Angular does not run the calculation until the computed signal is read, and it caches the result until one of its dependencies changes. That makes computed signals a good place for derived UI state, including filtered lists and calculated labels.

readonly products = signal<Product[]>([]);
readonly query = signal('');

readonly filteredProducts = computed(() => {
  const search = this.query().trim().toLowerCase();

  if (!search) {
    return this.products();
  }

  return this.products().filter(product =>
    product.name.toLowerCase().includes(search)
  );
});

This is cleaner than putting filtering logic directly in the template or recalculating it inside a method on every check.

Signals and OnPush Fit Together Naturally

Signals solve one of the most annoying OnPush questions: “How does Angular know this component needs to update?”

When a template reads a signal inside an OnPush component, Angular tracks that signal as a dependency of the component. When the signal changes, Angular marks the component so it can update on the next change detection run.

import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-counter',
  template: `
    <button type="button" (click)="increment()">+</button>
    <p>Count: {{ count() }}</p>
  `,
})
export class CounterComponent {
  readonly count = signal(0);

  increment() {
    this.count.update(value => value + 1);
  }
}

No manual markForCheck() is needed here.

The template reads count().

The click handler updates the signal.

Angular knows the template depends on that signal.

That is the modern Angular rhythm.

Signals Do Not Make Deep Mutation Safe

Signals are not magic containers that detect every deep mutation inside an object.

This is still risky:

this.user().name = 'Linus';

The object inside the signal was mutated, but the signal itself was not updated through .set() or .update().

Use this instead:

this.user.update(user => ({
  ...user,
  name: 'Linus',
}));

Angular’s docs note that readonly signals do not prevent deep mutation, and signals use referential equality by default through Object.is(), unless you provide a custom equality function.

So the practical rule is simple:

Treat signal values as immutable unless you have a very deliberate reason not to.

Signal Inputs Make Component APIs Cleaner

Angular’s input() API lets component inputs behave like signals. Instead of declaring an @Input() property and reading it as a normal class field, you declare an input signal and read it by calling it.

import { Component, computed, input } from '@angular/core';

@Component({
  selector: 'app-user-badge',
  template: `
    <span>{{ label() }}</span>
  `,
})
export class UserBadgeComponent {
  readonly name = input.required<string>();

  readonly label = computed(() => `User: ${this.name()}`);
}

The input() function returns an InputSignal, and required inputs can be declared with input.required(). Angular enforces required inputs at build time when a component is used in a template.

Angular still supports the decorator-based @Input() API, but the Angular team recommends signal-based inputs for new projects.

Signal inputs are especially helpful because they make derived values straightforward:

readonly fullName = computed(() =>
  `${this.firstName()} ${this.lastName()}`
);

No setter.

No lifecycle hook.

No manual copying from one property into another.

Just an input signal and a computed signal.

Zoneless Angular: Change Detection Without Zone.js

Zoneless Angular removes Zone.js from the center of the change detection scheduling model.

Zone.js watches browser async activity and tells Angular that something may have changed. Zoneless Angular does not use Zone.js state changes to schedule change detection. Instead, Angular relies on notifications from Angular APIs and reactive state.

Zoneless is the default in Angular v21 and later. In Angular v20, it can be enabled with provideZonelessChangeDetection(), which became stable in v20.2.

import { provideZonelessChangeDetection } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';

bootstrapApplication(AppComponent, {
  providers: [
    provideZonelessChangeDetection(),
  ],
});

For zoneless applications, Angular recommends removing Zone.js from the build and removing zone.js and zone.js/testing from polyfills.

Zoneless does not mean “no change detection.” It means Angular no longer depends on Zone.js to decide when change detection should run.

The Zoneless Rule: Notify Angular Clearly

In zoneless Angular, the most important question is:

Did this state change notify Angular?

Angular’s zoneless guide lists the core notifications Angular relies on, including ChangeDetectorRef.markForCheck(), ComponentRef.setInput(), updating a signal that is read in a template, bound host or template listener callbacks, and attaching a view that was already marked dirty.

That gives us a practical map:

The best debugging question is no longer “Did Zone.js notice something?”

It is:

Where is the notification Angular understands?

RxJS Still Belongs in Modern Angular

Signals do not replace RxJS. They solve a different problem.

RxJS is still excellent for async streams, cancellation, retries, websockets, event pipelines, and complex async composition. Signals are usually better for current UI state and derived template state.

For templates, AsyncPipe remains a strong option:

@if (user$ | async; as user) {
  <p>{{ user.name }}</p>
}

Angular’s zoneless guide notes that AsyncPipe automatically calls markForCheck(), which makes it compatible with the notification model used by OnPush and zoneless Angular. (angular.dev)

When you want an Observable’s latest value to behave like state in component code, use toSignal():

import { toSignal } from '@angular/core/rxjs-interop';

readonly user = toSignal(this.userService.user$, {
  initialValue: null,
});

toSignal() subscribes to the Observable immediately, automatically unsubscribes when the creating component or service is destroyed, and should not be called repeatedly for the same Observable. Reuse the signal it returns.

A good rule of thumb:

Use RxJS for streams.

Use signals for state.

Use toSignal() when a stream becomes component state.

Effects Are for Side Effects, Not Everyday Derived State

Signals include effect(), but it should not be your first tool for deriving values.

Use computed() when one value can be calculated from another:

readonly fullName = computed(() =>
  `${this.firstName()} ${this.lastName()}`
);

Use effect() when a signal change needs to interact with something outside Angular’s normal template rendering flow, such as logging, local storage, canvas, analytics, or a third-party chart library. Angular’s signals guide recommends using derivations to respond to state changes and reserves effects for specific cases involving non-reactive APIs.

A simple test helps:

If the result is another value used by the template, use computed().

If the result is an external side effect, consider effect().

Gotcha: Manually Changing Child Inputs

OnPush can expose bugs caused by imperative updates to components.

@ViewChild(UserCardComponent)
userCard!: UserCardComponent;

rename() {
  this.userCard.user = { name: 'Ada' };
}

This bypasses normal input binding. Angular’s docs warn that when you manually modify an input through APIs such as @ViewChild or @ContentChild, Angular will not automatically run change detection for OnPush components. If you need Angular to schedule a check, call ChangeDetectorRef.markForCheck().

Prefer a declarative binding:

<app-user-card [user]="selectedUser()" />

Then update the signal:

this.selectedUser.set({ name: 'Ada' });

Let the binding and the signal notify Angular.

Gotcha: Projected Content Belongs to the Parent

Content projection includes a change-detection detail that surprises many developers.

When content is projected into a component with <ng-content>, that projected content is still owned by the component that declared it. Angular checks projected content when the parent view runs change detection. If the receiving component uses OnPush, Angular can skip that component’s own template, but it does not automatically skip the projected content.

<app-onpush-shell>
  <app-expensive-widget />
</app-onpush-shell>

Even if app-onpush-shell uses OnPush, app-expensive-widget may still be checked as part of the parent view.

OnPush is powerful, but it is not a force field around everything placed between a component’s opening and closing tags.

Gotcha: Reactive Forms in Zoneless Apps

Reactive forms deserve special attention in zoneless applications.

Angular’s zoneless guide notes that reactive forms APIs such as setValue, patchValue, and FormArray.push update form state and emit form observables, but they do not automatically schedule component change detection. If a template depends on reactive form state, connect form changes to a notification, such as markForCheck(), or reflect the data via signals consumed by the template.

That means this kind of form update may need an Angular-friendly bridge:

this.form.patchValue({
  name: 'Ada',
});

A signal-based bridge can make the dependency clear:

readonly name = toSignal(
  this.form.controls.name.valueChanges,
  { initialValue: this.form.controls.name.value }
);

Then the template reads the signal:

<p>Name: {{ name() }}</p>

The important point is not that every form needs signals. The point is that zoneless Angular needs a clear notification path when form state is displayed in the template.

Gotcha: Third-Party Libraries and Noisy Async Work

In Zone.js-based applications, third-party libraries can cause unnecessary change detection by scheduling timers, animation frames, event listeners, or network tasks inside Angular’s zone. Angular’s docs recommend using Angular DevTools to identify these extra checks and NgZone.runOutsideAngular() when work does not need to trigger Angular updates.

import { Component, NgZone, inject } from '@angular/core';

@Component({
  selector: 'app-chart',
  template: `<div id="chart"></div>`,
})
export class ChartComponent {
  private readonly ngZone = inject(NgZone);

  ngOnInit() {
    this.ngZone.runOutsideAngular(() => {
      initializeChartLibrary();
    });
  }
}

If a callback from that library later needs to update Angular state, update a signal, emit through an Angular output, or call markForCheck(), do so.

readonly selectedPoint = signal<Point | null>(null);

ngOnInit() {
  this.ngZone.runOutsideAngular(() => {
    chart.on('pointSelected', point => {
      this.selectedPoint.set(point);
    });
  });
}

In zone-less Angular, Zone.js no longer schedules change detection, but the same principle still applies: external code should update Angular state through clear, Angular-friendly APIs.

A Practical Modern Component

Here is a small example that brings the pieces together: signals for local state, toSignal() for Observable data, computed() for derived state, and template reads that Angular can track.

import { Component, computed, inject, signal } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { ProductService } from './product.service';

@Component({
  selector: 'app-product-search',
  template: `
    <label>
      Search
      <input
        [value]="query()"
        (input)="query.set($any($event.target).value)"
      />
    </label>

    <p>{{ resultCount() }} products found</p>

    <ul>
      @for (product of filteredProducts(); track product.id) {
        <li>{{ product.name }}</li>
      }
    </ul>
  `,
})
export class ProductSearchComponent {
  private readonly productService = inject(ProductService);

  readonly query = signal('');

  readonly products = toSignal(this.productService.products$, {
    initialValue: [],
  });

  readonly filteredProducts = computed(() => {
    const search = this.query().trim().toLowerCase();

    if (!search) {
      return this.products();
    }

    return this.products().filter(product =>
      product.name.toLowerCase().includes(search)
    );
  });

  readonly resultCount = computed(() => this.filteredProducts().length);
}

There is no manual subscription.

There is no lifecycle hook just to copy data.

There is no detectChanges() call.

No filtering method is being called directly from the template.

The input event updates a signal. The Observable becomes a signal. The filtered list is computed. The template reads the signals. Angular can see the dependencies clearly.

When markForCheck() Is Still the Right Tool

markForCheck() is not a failure. It is the right tool when Angular needs to be told that a view should be checked and no other notification has done that job.

Angular’s ChangeDetectorRef docs describe markForCheck() as explicitly marking an OnPush view as changed so it can be checked again, especially when normal triggers such as changed inputs or view events have not occurred.

You may need it when:

Example:

import { ChangeDetectorRef, inject } from '@angular/core';

private readonly cdr = inject(ChangeDetectorRef);

ngOnInit() {
  externalWidget.onChange(value => {
    this.value = value;
    this.cdr.markForCheck();
  });
}

A signal-based version is often cleaner:

readonly value = signal('');

ngOnInit() {
  externalWidget.onChange(value => {
    this.value.set(value);
  });
}

The state update itself becomes the notification.

Migrating Without Pain

Do not migrate an old Angular app by flipping every switch at once. Move toward explicit notifications step by step.

Start with components that already have simple inputs and predictable templates. Replace object and array mutations with immutable updates. Move local UI state into signals. Replace derived template methods with computed(). Use AsyncPipe or toSignal() instead of manual subscriptions. Avoid manually mutating child inputs. Review third-party callbacks and reactive forms in zoneless areas.

For zoneless compatibility, Angular recommends using OnPush-compatible patterns because they help ensure components notify Angular correctly through signals, AsyncPipe, markForCheck(), template listeners, and other supported APIs. Angular also notes that NgZone.onMicrotaskEmpty, NgZone.onUnstable, and NgZone.onStable do not emit in zoneless applications, while NgZone.isStable is always true.

Code like this should be reviewed:

this.ngZone.onStable.subscribe(() => {
  this.measureLayout();
});

Depending on the goal, a render hook, a signal, a direct DOM API, or a MutationObserver may be a better fit.

The Simple Debugging Checklist

When an Angular view does not update, ask these questions:

Most change detection bugs become easier once you stop asking, “Why didn’t Angular notice?” and start asking, “Where was Angular notified?”

Final Takeaway

OnPush, signals, and zoneless Angular are not separate tricks. They are part of the same direction: Angular is moving away from broad, implicit checking and toward precise, understandable updates.

OnPush reduces unnecessary checks.

Signals make state dependencies visible.

Zoneless Angular removes Zone.js from the update scheduler and relies on clear Angular notifications.

The best modern Angular code has a simple pattern:

State changes clearly.

Templates read state clearly.

Angular receives clear notifications.

The DOM updates for clear reasons.

That is change detection without the gotchas.


메타데이터
post_id
6414a96e4db5
slug
change-detection-without-the-gotchas-angular-onpush-signals-zoneless-explained-6414a96e4db5
url
https://medium.com/@QuarkAndCode/change-detection-without-the-gotchas-angular-onpush-signals-zoneless-explained-6414a96e4db5
canonical_url
https://medium.com/@QuarkAndCode/change-detection-without-the-gotchas-angular-onpush-signals-zoneless-explained-6414a96e4db5
author_url
https://medium.com/@QuarkAndCode
status
ok
fetched_at
2026-07-17 02:27:48