<link rel="stylesheet" href="assets/fonts/inter.css"> Angular Signals Complete Guide 2025: Build Reactive… | Viral Patel Studio Skip to main content

Angular Signals Complete Guide 2025: Build Reactive Design System Components

Dec 20, 2025By Viral Patel

Master Angular Signals in 2025. Learn input(), output(), computed(), effect(), and model() to build simpler, faster reactive design system components.

Angular Signals Complete Guide 2025: Build Reactive Design System Components

Angular Signals Reactive Flow

learn about Angular

design system

design systems

angular

Expert NX Monorepo for Angular Design Systems: Enterprise Guide 2025 Figure 1: Angular Signals provide fine-grained reactivity — only components that read a changed Signal re-render

Why Signals Change Everything for Design Systems

Before Signals, building reactive design system components in Angular required a mix of @Input() decorators, OnChanges lifecycle hooks, EventEmitter, BehaviorSubject, async pipes, and manual markForCheck() calls. The mental model was scattered across multiple APIs.

Angular Signals, stabilized in Angular 17 and expanded in Angular 18–19, consolidate this into one coherent reactive primitive. For design system component libraries, they unlock:

  • Simpler APIs — input() replaces @Input() + ngOnChanges
  • Better performance — fine-grained change detection; only affected components re-render
  • Zoneless readiness — Signals work without Zone.js, enabling faster apps
  • Safer types — required inputs are enforced at the type level, not just at runtime

This guide covers every Signal API you'll use when building a design system.


The Signal Primitive

A Signal is a readable wrapper around a value. Call it to read; call .set() or .update() to write:

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

// Create a writable signal
const count = signal(0);

// Read the current value
console.log(count()); // 0

// Write a new value
count.set(1);

// Update based on current value
count.update(n => n + 1);
console.log(count()); // 2

Three derived Signal APIs compose on top of this foundation:

API Purpose Writable?
signal(value) Writable state Yes
computed(() => expr) Derived, memoized value No
effect(() => sideEffect) Reactive side effect No

input() — Signal-Based Component Inputs

input() (Angular 17.1+) replaces the @Input() decorator with a Signal that is automatically reactive:

// Before: @Input() with ngOnChanges
@Component({ selector: 'ds-badge' })
export class BadgeComponent implements OnChanges {
  @Input() count = 0;
  @Input() variant: 'info' | 'success' | 'warning' | 'error' = 'info';

  ngOnChanges(changes: SimpleChanges) {
    if (changes['count']) {
      this.updateDisplay();
    }
  }
}

// After: input() — no lifecycle hooks needed
@Component({ selector: 'ds-badge', standalone: true })
export class BadgeComponent {
  count = input(0);  // Signal<number>, default 0
  variant = input<'info' | 'success' | 'warning' | 'error'>('info');

  // Derived value reacts automatically when count changes
  displayCount = computed(() =>
    this.count() > 99 ? '99+' : String(this.count())
  );
}

Required Inputs

Enforce required props at the type level — consumers get a compile error, not a runtime bug:

@Component({ selector: 'ds-avatar' })
export class AvatarComponent {
  // Type-safe required input — no default value
  name = input.required<string>();
  src = input.required<string>();
  size = input<'sm' | 'md' | 'lg'>('md');
}

Usage without name or src now produces a TypeScript error — far better than discovering it at runtime.

Transform Inputs

Apply transformations at the boundary:

import { booleanAttribute, numberAttribute } from '@angular/core';

@Component({ selector: 'ds-button' })
export class ButtonComponent {
  // Accepts string "true"/"false" from templates, delivers boolean
  disabled = input(false, { transform: booleanAttribute });

  // Accepts string "12" from templates, delivers number
  tabIndex = input(0, { transform: numberAttribute });
}

output() — Signal-Based Component Outputs

output() (Angular 17.3+) replaces @Output() EventEmitter with a cleaner API:

// Before
@Output() valueChange = new EventEmitter<string>();
@Output() closed = new EventEmitter<void>();

// After
valueChange = output<string>();
closed = output();

// Emit the same way
this.valueChange.emit(newValue);
this.closed.emit();

The key difference: output() returns an OutputEmitterRef, not an Observable. It's a thin, synchronous emitter — no RxJS overhead for simple events.


model() — Two-Way Binding Made Simple

model() (Angular 17.2+) combines input() + output() for two-way data binding:

@Component({
  selector: 'ds-toggle',
  standalone: true,
  template: `
    <button
      role="switch"
      [attr.aria-checked]="checked()"
      (click)="toggle()"
      [class.active]="checked()"
    >
      <span class="thumb" />
    </button>
  `,
})
export class ToggleComponent {
  checked = model(false);  // Signal + two-way binding in one

  toggle() {
    this.checked.update(v => !v);
  }
}

In the parent template, use standard [(checked)] banana-in-a-box syntax:

<ds-toggle [(checked)]="isDarkMode" />

model() is the Signal replacement for the @Input() value + @Output() valueChange pattern used in every form control in your design system.


computed() — Derived State

computed() creates a memoized derived value that recalculates only when its Signal dependencies change:

@Component({ selector: 'ds-progress-bar', standalone: true })
export class ProgressBarComponent {
  value = input.required<number>();
  max = input(100);
  label = input<string>();

  // Recomputes only when value or max changes
  percentage = computed(() =>
    Math.min(100, Math.round((this.value() / this.max()) * 100))
  );

  ariaLabel = computed(() =>
    this.label() ?? `Progress: ${this.percentage()}%`
  );

  progressColor = computed(() => {
    const pct = this.percentage();
    if (pct >= 80) return 'var(--color-success)';
    if (pct >= 40) return 'var(--color-warning)';
    return 'var(--color-error)';
  });
}

The template reads these directly — no async pipe, no subscription:

<div
  role="progressbar"
  [attr.aria-valuenow]="value()"
  [attr.aria-valuemax]="max()"
  [attr.aria-label]="ariaLabel()"
>
  <div
    class="fill"
    [style.width.%]="percentage()"
    [style.background]="progressColor()"
  />
</div>

effect() — Reactive Side Effects

effect() runs a function whenever any Signal it reads changes. Use it for side effects — logging, DOM operations, localStorage sync — not for computing derived values:

@Component({ selector: 'ds-theme-switcher', standalone: true })
export class ThemeSwitcherComponent {
  theme = model<'light' | 'dark'>('light');

  constructor() {
    // Runs whenever theme changes
    effect(() => {
      document.documentElement.setAttribute('data-theme', this.theme());
      localStorage.setItem('ds-theme', this.theme());
    });
  }
}

Rules for effect()

  1. Don't write to Signals inside effect() — use computed() for derived writes
  2. Use allowSignalWrites: true only when absolutely necessary (rare edge cases)
  3. Effects are cleaned up automatically when the component is destroyed — no ngOnDestroy needed

RxJS Interop: toSignal() and toObservable()

Design systems often need to bridge Signals and existing RxJS-based services:

import { toSignal, toObservable } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';

@Component({ selector: 'ds-async-select', standalone: true })
export class AsyncSelectComponent {
  private http = inject(HttpClient);

  searchTerm = signal('');

  // Convert Signal → Observable to use RxJS operators
  private searchTerm$ = toObservable(this.searchTerm).pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(term =>
      term.length > 2
        ? this.http.get<Option[]>(`/api/search?q=${term}`)
        : of([])
    )
  );

  // Convert Observable back → Signal for the template
  options = toSignal(this.searchTerm$, { initialValue: [] as Option[] });
  isLoading = toSignal(
    this.searchTerm$.pipe(
      switchMap(() => merge(of(true), this.searchTerm$.pipe(map(() => false))))
    ),
    { initialValue: false }
  );
}

The template reads options() and isLoading() as plain Signals — no async pipe required.


A Complete Design System Component: DatePicker

Putting it all together — a real-world design system component using the full Signals API:

// ds-date-input.component.ts
import {
  Component, computed, effect, input, model, output, signal
} from '@angular/core';

@Component({
  selector: 'ds-date-input',
  standalone: true,
  template: `
    <div class="ds-date-input" [class.error]="hasError()">
      <label [for]="inputId">
        {{ label() }}
        @if (required()) { <span class="required">*</span> }
      </label>

      <input
        type="date"
        [id]="inputId"
        [value]="value()"
        [min]="min()"
        [max]="max()"
        [attr.aria-invalid]="hasError()"
        [attr.aria-describedby]="hasError() ? errorId : null"
        (change)="handleChange($event)"
        (blur)="touched.set(true)"
      />

      @if (hasError()) {
        <span [id]="errorId" class="error-message" role="alert">
          {{ errorMessage() }}
        </span>
      }
    </div>
  `,
})
export class DateInputComponent {
  // Inputs
  label    = input.required<string>();
  min      = input<string>();
  max      = input<string>();
  required = input(false, { transform: booleanAttribute });

  // Two-way binding
  value = model<string>('');

  // Output
  dateSelected = output<Date>();

  // Internal state
  touched = signal(false);

  // Stable IDs for ARIA
  private readonly uid = Math.random().toString(36).slice(2, 8);
  readonly inputId = `ds-date-${this.uid}`;
  readonly errorId = `ds-date-error-${this.uid}`;

  // Derived
  isEmpty = computed(() => !this.value());

  errorMessage = computed(() => {
    if (!this.touched()) return null;
    if (this.required() && this.isEmpty()) return 'This field is required.';
    const d = new Date(this.value());
    if (isNaN(d.getTime())) return 'Please enter a valid date.';
    return null;
  });

  hasError = computed(() => !!this.errorMessage());

  constructor() {
    // Log valid date selections for analytics
    effect(() => {
      const d = new Date(this.value());
      if (!isNaN(d.getTime())) {
        this.dateSelected.emit(d);
      }
    });
  }

  handleChange(event: Event) {
    const input = event.target as HTMLInputElement;
    this.value.set(input.value);
    this.touched.set(true);
  }
}

Notice what's absent: no ngOnChanges, no Subject, no async pipe, no markForCheck(), no ngOnDestroy.


Signals and Zoneless Angular

Angular's Signals-based change detection is the foundation for zoneless Angular — running without Zone.js entirely. You can opt in per-component today:

@Component({
  selector: 'ds-button',
  changeDetection: ChangeDetectionStrategy.OnPush,  // Step 1: OnPush
  // ...
})

For new design system libraries targeting Angular 18+, enabling OnPush globally and relying on Signals for all state changes prepares your library for the upcoming zoneless default.


Migrating @Input() to input()

For existing design system components, migrate incrementally — one component at a time:

// Step 1: Replace @Input() with input()
// Before:
@Input() size: 'sm' | 'md' | 'lg' = 'md';

// After:
size = input<'sm' | 'md' | 'lg'>('md');

// Step 2: Replace ngOnChanges with computed()
// Before:
ngOnChanges() { this.classes = this.buildClasses(); }
buildClasses() { return `btn btn-${this.size}`; }

// After:
classes = computed(() => `btn btn-${this.size()}`);

// Step 3: Replace @Output() EventEmitter with output()
// Before:
@Output() sizeChanged = new EventEmitter<string>();

// After:
sizeChanged = output<string>();

// Step 4: Replace BehaviorSubject with signal()
// Before:
private _loading$ = new BehaviorSubject(false);
loading$ = this._loading$.asObservable();

// After:
loading = signal(false);

The Angular team provides a migration schematic for automated conversion:

ng generate @angular/core:signal-input-migration
ng generate @angular/core:signal-queries-migration
ng generate @angular/core:output-migration

Run these in your library project to migrate the entire codebase in minutes.


When to Stick with RxJS

Signals don't replace RxJS — they complement it. Keep using Observables for:

  • HTTP requests — HttpClient returns Observables; wrap with toSignal() at the component boundary
  • WebSockets and real-time streams — Observables model multi-value async streams naturally
  • Complex async operators — switchMap, combineLatest, retry, debounceTime
  • NgRx / state management libraries — most are Observable-based; Signals interop via toSignal()

Use Signals for:

  • Component inputs and outputs — input(), output(), model()
  • Local component state — signal() replacing BehaviorSubject for simple values
  • Derived UI state — computed() replacing ngOnChanges + manual calculations
  • Synchronous side effects — effect() for DOM operations, localStorage, analytics

The Signal-Driven Design System Checklist

Before publishing your Signal-based component library:

  • All new inputs use input() or input.required()
  • All new outputs use output(), not EventEmitter
  • Two-way binding controls use model()
  • Derived values use computed(), not ngOnChanges
  • ChangeDetectionStrategy.OnPush on every component
  • toSignal() wraps any Observable at the component boundary
  • No direct Signal writes inside effect() calls
  • Stable ARIA IDs generated as component-level constants
  • Migration schematics run and verified

Angular Signals are not an optional nicety in 2025 — they are the API surface your design system components should be built on. They make component logic readable, testable, and ready for the zoneless future.