Fine-Grained Change Detection with Angular Signal Inputs
Angular 17's signal inputs let components opt out of Zone.js change detection, updating only when their input signals actually change. This blog walks through the problem, the API, a worked counter example, trade-offs, and a quick way to verify the gains.
26 Dec 2025, 15:21 UTC

Why Zone.js Can Overwork Your Components
Angular's default change detection runs after every asynchronous event—clicks, timers, HTTP responses, even third-party library callbacks. Zone.js patches these APIs and forces a full component tree check, even when only a single leaf component actually needs updating. In large applications this dirty-checking can become a measurable performance tax, especially when many components receive @Input bindings that rarely change.
Signal Inputs: A Pull-Based Alternative
Since Angular 17, the input() and model() functions let a component declare its inputs as signals. A signal is a synchronous, pull-based reactive primitive: the framework only schedules change detection for a component when one of its signal inputs emits a new value. This moves the decision from "something happened somewhere" to "this specific value changed".
Key differences from the decorator-based API:
- Required by default –
input.required()or a default value must be supplied; there is no optional@Input(). - Two-way binding uses
model()– replaces the@Input()/@Output()pair and[(ngModel)]. - Equality is
Object.is()– mutating an object or array without creating a new reference will not trigger an update.
Worked Example: A Counter Child Component
The following standalone component demonstrates a writable signal input (count) and a two-way signal (step). The parent can bind to both using the new syntax.
import { Component, input, model, computed, effect } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Count: {{ count() }}</p>
<p>Step: {{ step() }}</p>
<button (click)="increment()">Increment</button>
<input type="number" [value]="step()" (input)="step.set($event.target.valueAsNumber)" />
`,
})
export class CounterComponent {
// Required signal input – parent must provide a value
count = input.required<number>();
// Two-way signal with default
step = model(1);
// Derived signal – recomputes only when count or step changes
next = computed(() => this.count() + this.step());
// Side-effect runs synchronously after any dependency changes
effect(() => {
console.log('Counter updated:', this.count(), 'step:', this.step());
});
increment() {
this.count.update(v => v + this.step());
}
}
Parent usage:
<app-counter [count]="total" [step]="stepSize" />
<!-- two-way binding for step -->
<app-counter [count]="total" [(step)]="stepSize" />
When the parent updates total (a signal in the parent), only CounterComponent and its descendants run change detection. Siblings that do not read total are untouched.
Trade-Offs and Gotchas
| Aspect | Decorator @Input | Signal input() |
|---|---|---|
| Optionality | Optional by default | Required unless default provided |
| Change detection trigger | Zone.js macro-task | Synchronous on signal write |
| Equality check | Reference check (same as Object.is) | Object.is – no custom comparator |
| Two-way binding | @Input + @Output | model() |
| Testing | fakeAsync/tick | provideExperimentalZonelessChangeDetection() |
Limitations to keep in mind:
- No built-in reset – you must store the initial value yourself if you need a "reset" operation.
- Zoneless change detection is experimental (v18+) – third-party libraries that still rely on Zone.js may cause missed updates.
- Complex async orchestration (retries, back-pressure, race conditions) remains better suited to RxJS; signals are not a drop-in replacement for every Observable use case.
- Migration is manual –
ng updatedoes not convert@Input/@Outputto signal APIs.
Next Steps: Try It in a Standalone Project
- Create a fresh Angular 17+ workspace:
ng new signal-demo --standalone --routing=false --style=css. - Add the
CounterComponentabove tosrc/app/counter.component.ts. - In
app.component.ts, create a signal for the total and a signal for step size, then bind them in the template. - Run
ng serveand open Chrome DevTools → Performance. Record a click on "Increment" and observe that only the counter component's change detection fires. - For unit tests, configure the test module with
provideExperimentalZonelessChangeDetection()and verify thatfixture.detectChanges()runs synchronously after a signal update—nofakeAsync/tickneeded.
If the profile shows a reduced number of change detection cycles and the test suite passes without zone helpers, you have a concrete signal that the migration is paying off.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.