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

Claire ChengClaire Cheng
8 min read

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, unlike HttpClient’s Observable-based approach. Simplify async 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 the subscription, 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 TypeScriptno 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-renderwithout 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. Keep effect 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 inside computed() or effect().

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:

  1. 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>
  1. 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.

0
Subscribe to my newsletter

Read articles from Claire Cheng directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Claire Cheng
Claire Cheng