Building with Signals: Transforming Angular with Practical Reactivity (3/4)


Key Signal Features in Angular
Signals are transforming Angular, powering forms, async data, and UI updates. The RFC: Complete Signal API and Angular roadmap show how Signals, zoneless change detection, and other features work together.
Signal-based Features
These use Signals for reactivity and work in both zoned and zoneless apps:
signal()
,computed()
,effect()
:Stable since v17 (introduced in v16)
Purpose: Enable precise reactivity for state management.
(
input()
,model()
) Signal-based Inputs & Model Bindings:Stable since v17
Purpose: Reactive inputs and two-way binding.
(
viewChild
,contentChildren
) Signal Queries:Stable since v18
Purpose: Track component queries as Signals.
Signal-based Reactive Forms :
Stable since v19
Purpose: Signal-driven form state.
(
resource
/rxResource
/httpResource
) Resource API :Experimental in v19
Purpose:
Signal-based
async data fetching, unlikeHttpClient’s Observable-based approach
. Simplifyasync
operations with a Signal-driven API, handling data, loading, and errors automatically.
These Signal-driven features
make apps reactive, simple, and fast, paving the way for Angular’s zoneless future.
Picture this:
You’re building a modern Angular app. You’ve embracedOnPush
change detection, manage state with Observables, and optimize rendering to avoid unnecessary updates. As your app grows, so does the boilerplate. Templates become tightly coupled to streams. You juggle async pipes, imperative subscriptions, and the occasional combineLatest()
just to compute a single UI value. You’ve optimized the engine. But what if the engine itself evolved?
The subtle cost of OnPush + Observables
Using OnPush
with Observables
works, but it comes with some cost.
In templates, you often end up using the
async
pipe everywhere. It makes the code more verbose and tightly couples your view to Observables. You also need to handle the loading state manually.In TypeScript, getting a value out of an Observable means you need to
subscribe
, manage thesubscription
, and remember to clean it up. If you forget, it might cause memory leaks or unexpected behavior. This adds boilerplate and makes the logic harder to follow.
Take this example:
import { Component, ChangeDetectionStrategy, OnInit, OnDestroy } from '@angular/core';
import { Store } from '@ngrx/store';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-counter',
template: `
<p>Count: {{ (counter$ | async) || 0 }}</p>
<button (click)="increment()">Add One</button>
<p>Click history: {{ clickHistory.join(', ') }}</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CounterComponent implements OnInit, OnDestroy {
counter$ = this.store.select(selectCounter);
clickHistory: number[] = [];
private subscription: Subscription;
constructor(private store: Store) {}
ngOnInit() {
this.subscription = this.counter$.subscribe(count => {
this.clickHistory = [...this.clickHistory, count]; // Track count history
});
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
increment() {
this.store.dispatch(increment());
}
}
The async
pipe adds extra syntax to your templates. In TypeScript, working with Observables usually means managing subscriptions
and handling cleanup in lifecycle hooks.
Signals remove that overhead. You can read the value directly in both templates and TypeScript—no pipes
, no subscriptions
.
Signals : Reactivity designed for the template
A Signal is a reactive value that tracks its own changes. When it updates, Angular knows exactly what needs to re-render
—without streams, pipes, or subscriptions. It keeps your code simple, and makes OnPush apps run even faster.
Let’s see how it works in practice.
Let’s explore signals
Signals : The Basic Unit
A signal is a reactive primitive
that holds a value
. You read
it by calling it like a function. You write
it by calling .set()
.
import { signal } from '@angular/core';
const count = signal(0);
count(); // Read: 0
count.set(1); // Write: 1
Computed Signals:Derive from Signals
computed()
lets you derive a value from other Signals. It’s lazy and memoized: it won’t recalculate unless something it depends on changes, and it won’t run until you access it.
import { computed } from '@angular/core';
const double = computed(() => count() * 2);
This is the reactive equivalent of a getter that auto-updates when the source signals change.
Effect : React to Changes
Effects
let you run side effects when signals change. The nice part? Angular cleans it up automatically when the component is destroyed. No need for manual teardown
⚠️ Note: Don’t change Signals inside an
effect
—it can create loops and mess things up. Keepeffect
for side tasks like logging or syncing data.
effect(() => {
console.log(count()); // Safe
// Avoid: count.set(42); // Infinite loop risk!
});
Interop with RxJS: toSignal and toObservable
toSignal : From Observable to Signal
If you want to turn an Observable into a Signal, toSignal()
is the tool for the job. But there’s one important thing to know: it must run inside an Angular injection context—like a component, directive, or a block wrapped in runInInjectionContext().
import { toSignal } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
const time$ = interval(1000);
const time = toSignal(time$, { initialValue: 0 });
toObservable: Signal to Observable
toObservable()
turns a Signal into an Observable, useful for RxJS pipelines.
import { signal, toObservable } from '@angular/core/rxjs-interop';
const count = signal(0);
const count$ = toObservable(count);
count$.subscribe(value => console.log(`Count: ${value}`));
count.set(1); // Logs: Count: 1
Reactive Components with Inputs, Outputs, Model and ViewChild
Signals aren’t just simplifying state management—they’re also changing the way we work with components and the view layer itself.
Signal inputs:Reactive Property Binding
input()
—a signal-friendly alternative to @Input()
. It creates a read-only
Signal for component inputs,
Why Not @Input()
?
Traditional
@Input()
values aren’t reactive. They’re assigned when the parent changes, but they don’t automatically notify downstream reactive logic unless you manually track them.Signals
, on the other hand, are designed for dependency tracking.input()
returns a Signal—automatically wired to the input binding—so you can use it reactively insidecomputed()
oreffect()
.
What you get is a readonly Signal that reflects the current value reactively.
You cannot set it—because it’s driven by the parent.
import { Component, input } from '@angular/core';
@Component({
selector: 'app-greeter',
standalone: true,
template: `<p>Hello, {{ name() }}!</p>`
})
export class GreeterComponent {
name = input<string>();
}
// Parent component template example
<app-greeter [name]="'Claire'"></app-greeter>
@input
vs input()
Feature | @Input() | input() |
Reactive | ❌ No | ✅ Yes |
Works in computed | ❌ Manual | ✅ Native |
Read syntax in template | {{ name }} | {{ name() }} |
Writable | ✅ Yes | ❌ Readonly from parent |
Signal-like Outputs: A New Event Emission Model
Angular's output()
function introduces a new way to emit events to parent components. It's important to note that output()
doesn't return a Signal or an Observable—instead, it returns a specialized output object with an emit()
method for triggering events and built-in reactive capabilities.
This output object works in two ways:
- Template Listeners (Auto-Cleaned)
@Component({
standalone: true,
selector: 'app-greeter',
template: `
<button (click)="delete(1)">Delete Item</button>
`,
})
export class GreeterComponent {
deleteItem = output<number>(); // ← Only declare
delete(id: number) {
this.deleteItem.emit(id);
}
}
// Parent component template example
<app-greeter (deleteItem)="handler($event)"></app-greeter>
- Programmatic Subscriptions (Manual Cleanup Required)
It supports subscribe()
for programmatic event handling, alongside declarative template bindings (e.g., (countChange)="handle($event)"
).
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
export class GreeterComponent {
deleteItem = output<number>();
constructor() {
// Requires manual cleanup
this.deleteItem
.pipe(takeUntilDestroyed())
.subscribe(value => console.log(value));
}
}
Model Signals : Two-Way Binding
model()
enables Signal-based two-way binding, while ngModel
remains for template-driven forms.
@Component({
standalone: true,
template: `
<!-- it binds the input to name, syncing both ways-->
<input [(model)]="name" placeholder="Your name">
<p>Hello, {{ name() }}!</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NameComponent {
// creates a model Signal starting empty.
name = model('');
}
Signal Queries
Sometimes you need to watch a child component reactively. That’s where viewChild()
comes in. It works just like a signal, updating when the child appears or disappears in the view. No more boilerplate or side effects.
@Component({
selector: 'app-counter',
standalone: true,
template: `<button (click)="count.set(count() + 1)">Count: {{ count() }}</button>`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CounterComponent {
count = signal(0);
}
// parent component
@Component({
selector: 'app-parent',
standalone: true,
imports: [CounterComponent],
template: `
<app-counter />
<p>Parent sees: {{ childCount() ?? 'No child yet' }}</p>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ParentComponent {
counter = viewChild(CounterComponent);
childCount = computed(() => this.counter()?.count() ?? 0);
}
No ngAfterViewInit()
. Just clean, reactive access to a child’s state. And since this is all signal-based
, it works perfectly with OnPush and stays in sync when the view changes.
A Todo List Example
Let’s build a todo list where users can add and edit tasks, with a task count. We’ll use model()
for two-way binding:
import { Component, signal, computed, model } from '@angular/core';
@Component({
standalone: true,
template: `
<input #input placeholder="Add a task" (keyup.enter)="addTask(input.value); input.value = ''">
<p>Tasks left: {{ tasksLeft() }}</p>
<ul>
@for (task of tasks(); track task.id) {
<li>
<input [(model)]="task.value" placeholder="Edit task">
{{ task.value }}
</li>
}
</ul>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodoComponent {
// model Signal, for two-way binding
tasks = model<{ id: string; value: string }[]>([]);
// counts tasks reactively
tasksLeft = computed(() => this.tasks().length);
// Generate unique IDs
private getUniqueId(): string {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
}
addTask(value: string) {
if (value) {
// Add new task with unique ID
this.tasks.update(tasks => [...tasks, { id: this.getUniqueId(), value }]);
}
}
}
Type a task, hit Enter, and the list and count update. Edit a task, and model()
syncs smoothly, like Lego blocks snapping together.
Wrap-Up
Signals are changing the way we write Angular—making state updates simpler. With cleaner code, effortless OnPush, and powerful tools like model()
and input()
, the new reactivity model brings both clarity and control. Angular is moving toward a more reactive, streamlined future.
In this article, we stayed focused on how Signals simplify component state and interaction—without touching on zoneless or the internals (yet).
But there’s more to the story.
Subscribe to my newsletter
Read articles from Claire Cheng directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
