Angular Signals Explained With Real Examples
A hands-on walkthrough of Angular Signals — signal(), computed(), effect(), and how they change the way you write components.
Signals changed how I write Angular components more than any single feature since standalone components. Not because the API is exotic — it’s deliberately small — but because it removes an entire category of bugs and boilerplate around change detection that Angular developers have been quietly working around for years. This is a hands-on walkthrough, not a spec reading: real code, real gotchas.
signal(): A Reactive Box for a Value
A signal wraps a value and notifies consumers when it changes. You read it by calling it like a function.
import { signal } from '@angular/core';
const count = signal(0);
console.log(count()); // 0
count.set(5);
console.log(count()); // 5
count.update((current) => current + 1);
console.log(count()); // 6
.set() replaces the value outright. .update() derives the new value from the current one — use it whenever the new value depends on the old one, to avoid stale-closure bugs when multiple updates happen in quick succession.
computed(): Derived State Without Recomputing by Hand
computed() creates a read-only signal whose value is derived from other signals. It’s lazily evaluated and memoized — it only recalculates when one of its dependencies actually changes, and repeated reads between changes return the cached value.
import { signal, computed } from '@angular/core';
const price = signal(49.99);
const quantity = signal(2);
const taxRate = signal(0.08);
const subtotal = computed(() => price() * quantity());
const total = computed(() => subtotal() * (1 + taxRate()));
console.log(total()); // 107.978...
quantity.set(3);
console.log(total()); // recalculates: 161.967
Notice total depends on subtotal, which depends on price and quantity — computed signals compose naturally, and Angular tracks the whole dependency graph for you. There’s no manual subscription list to maintain.
effect(): Reacting to Changes Outside the Template
effect() runs a function whenever any signal it reads changes, and is meant for synchronizing with things outside Angular’s reactivity system — logging, localStorage, a third-party DOM library, analytics calls.
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-theme-toggle',
standalone: true,
template: `<button (click)="toggle()">Toggle theme</button>`,
})
export class ThemeToggleComponent {
darkMode = signal(false);
constructor() {
effect(() => {
document.body.classList.toggle('dark', this.darkMode());
localStorage.setItem('darkMode', JSON.stringify(this.darkMode()));
});
}
toggle() {
this.darkMode.update((v) => !v);
}
}
Important: effect() must generally be created in an injection context (a constructor, or a field initializer, or passed an explicit injector) — you can’t just call it anywhere.
Building Something Real: A Cart Quantity Counter
Here’s a small but realistic component — a cart line item with a quantity stepper and a computed line total, plus a parent that sums everything.
// cart-item.component.ts
import { Component, input, output, signal, computed } from '@angular/core';
@Component({
selector: 'app-cart-item',
standalone: true,
template: `
<div class="cart-item">
<span class="name">{{ name() }}</span>
<div class="stepper">
<button (click)="decrement()" [disabled]="quantity() <= 1">-</button>
<span>{{ quantity() }}</span>
<button (click)="increment()">+</button>
</div>
<span class="line-total">{{ lineTotal() | currency }}</span>
</div>
`,
})
export class CartItemComponent {
name = input.required<string>();
unitPrice = input.required<number>();
initialQuantity = input(1);
quantity = signal(1);
lineTotal = computed(() => this.quantity() * this.unitPrice());
quantityChanged = output<number>();
constructor() {
// sync the internal signal from the input once at creation
effect(() => this.quantity.set(this.initialQuantity()), { allowSignalWrites: true });
}
increment() {
this.quantity.update((q) => q + 1);
this.quantityChanged.emit(this.quantity());
}
decrement() {
this.quantity.update((q) => Math.max(1, q - 1));
this.quantityChanged.emit(this.quantity());
}
}
// cart.component.ts
import { Component, signal, computed } from '@angular/core';
import { CartItemComponent } from './cart-item.component';
interface CartLine {
id: string;
name: string;
unitPrice: number;
quantity: number;
}
@Component({
selector: 'app-cart',
standalone: true,
imports: [CartItemComponent],
template: `
@for (line of lines(); track line.id) {
<app-cart-item
[name]="line.name"
[unitPrice]="line.unitPrice"
[initialQuantity]="line.quantity"
(quantityChanged)="updateQuantity(line.id, $event)"
/>
}
<h3>Cart total: {{ cartTotal() | currency }}</h3>
`,
})
export class CartComponent {
lines = signal<CartLine[]>([
{ id: '1', name: 'Mechanical Keyboard', unitPrice: 89.99, quantity: 1 },
{ id: '2', name: 'USB-C Hub', unitPrice: 34.5, quantity: 2 },
]);
cartTotal = computed(() =>
this.lines().reduce((sum, line) => sum + line.unitPrice * line.quantity, 0)
);
updateQuantity(id: string, quantity: number) {
this.lines.update((lines) =>
lines.map((line) => (line.id === id ? { ...line, quantity } : line))
);
}
}
Note the lines.update() call builds a brand-new array with .map() rather than mutating an item in place. That’s deliberate — see the gotcha below.
Signal Inputs: input()
input() replaces @Input() for standalone, signal-based components. input.required<T>() enforces the input at compile time (no | undefined to deal with), and plain input(defaultValue) gives you an optional input with a default.
export class ProductCardComponent {
productId = input.required<string>();
highlighted = input(false);
variant = input<'compact' | 'full'>('full');
}
Inputs are themselves signals, so they compose directly with computed() — no separate ngOnChanges lifecycle hook needed to react to an input changing.
Gotchas
Mutating objects/arrays in place doesn’t notify consumers. Signals compare by reference for objects and arrays by default. If you do this.lines().push(newLine) instead of this.lines.update(arr => [...arr, newLine]), the array reference hasn’t changed, so Angular won’t know anything happened. Always create a new array/object when updating signal state that holds a collection.
Effects can run more often than you expect. An effect re-runs whenever any signal read inside its function body changes — including ones you didn’t mean to depend on. If your effect reads three signals but you only care about one of them, either read fewer signals inside it or restructure so the effect’s dependency surface is intentional. Logging effect(() => console.log(a(), b(), c())) will fire on a change to any of the three, even if your intent was just to watch a.
effect() can’t write to signals by default. Writing inside an effect is considered a smell — effects are for side effects, not deriving state (use computed() for that). If you truly need it (like the sync-from-input pattern above), you have to opt in with { allowSignalWrites: true }, and you should be sure you’re not creating an update loop.
Forgetting effects need an injection context. Creating an effect() inside a regular method call (outside the constructor or a field initializer) without passing an injector will throw at runtime. If you need to create one dynamically, pass { injector: this.injector } explicitly.
Treating computed() as if it has side effects. A computed() function should be pure — no HTTP calls, no logging, no mutating external state. If you need a side effect in response to a change, that’s what effect() is for.
Signal Queries: viewChild() and contentChild()
Signals extend beyond plain component state into querying the DOM and content projection. viewChild() and contentChild() return signals instead of requiring a static/dynamic flag and a separate AfterViewInit lifecycle hook to read the result safely.
import { Component, viewChild, ElementRef, effect } from '@angular/core';
@Component({
selector: 'app-auto-focus-input',
standalone: true,
template: `<input #searchBox type="text" />`,
})
export class AutoFocusInputComponent {
searchBox = viewChild.required<ElementRef<HTMLInputElement>>('searchBox');
constructor() {
effect(() => {
this.searchBox().nativeElement.focus();
});
}
}
Because viewChild() is a signal, you can compose it with computed() or read it inside an effect() without juggling undefined checks everywhere — it resolves once the view is initialized, and consuming it elsewhere in the component follows the same mental model as any other signal.
Why This Replaces a Lot of Manual Change Detection
Before signals, a common pattern for anything beyond the simplest components was either relying on Angular’s default (zone-triggered) change detection running on every browser event — which works but checks far more of the component tree than necessary — or switching to ChangeDetectionStrategy.OnPush and then manually calling markForCheck() whenever you mutated state outside of a template-bound event. Signals collapse that distinction: a signal-based component effectively gets OnPush-level precision automatically, because Angular knows exactly which signals a given piece of template reads and only re-renders that part of the tree when those specific signals change. You stop thinking about “did I remember to tell Angular something changed” and start just writing .set() or .update().
If you haven’t yet drawn the line between signals and RxJS for async work, Angular Signals vs RxJS: Which Should You Use and When? covers exactly that decision in depth. And if you want a broader look at where Angular’s reactivity model is heading, Angular 21 New Features Developers Should Know covers the general trajectory. For the TypeScript side of writing well-typed signal state (generics on signal<T>(), discriminated unions in computed state), see TypeScript Best Practices.
Related reading
Written by
Tayyab Roy
Senior Frontend / Full-Stack Engineer
Senior Frontend & Full-Stack Engineer specializing in Angular, React, Next.js and enterprise eCommerce (Shopify, SAP Commerce / Hybris, Spartacus). I write about practical, production-tested frontend engineering.
Related Articles
NgRx vs Signals: Choosing State Management
When a full NgRx store still earns its complexity in an Angular app, and when Signals-based state is simpler and sufficient.
RxJS vs Signals in Modern Angular Applications
How RxJS and Signals coexist in modern Angular apps, and practical patterns for using both together.
Angular Standalone Components: Why They Matter
Why standalone components became Angular's default architecture, and how to structure an app around them.