Angular Signals Complete Guide 2025: Build Reactive Design System Components

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()
- Don't write to Signals inside effect() — use
computed()for derived writes - Use
allowSignalWrites: trueonly when absolutely necessary (rare edge cases) - Effects are cleaned up automatically when the component is destroyed — no
ngOnDestroyneeded
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 —
HttpClientreturns Observables; wrap withtoSignal()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()replacingBehaviorSubjectfor simple values - Derived UI state —
computed()replacingngOnChanges+ 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()orinput.required() - All new outputs use
output(), notEventEmitter - Two-way binding controls use
model() - Derived values use
computed(), notngOnChanges -
ChangeDetectionStrategy.OnPushon 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.