Angular signals track synchronous state dependencies precisely. Learn writable signals, computed derivation, effects, component input and model APIs, service state, and RxJS interop without turning every value into global mutable state.
A signal is a synchronous getter that publishes a value and records reactive consumers when they read it. A writable signal adds set and update operations; computed, input, model, and query signals expose specialized read behavior.
When a template reads a signal, Angular records that dependency and schedules the consuming view when the value changes. Computed values and effects also track only the signals read during their latest execution, so dependencies can change when branches change.
Use signals for owned state and derivation, not as a wrapper around every constant or local variable:
Signal dependencies are dynamic. A computed callback that reads premiumRate only while isPremium is true stops depending on premiumRate when that branch is skipped. This prevents unrelated writes from invalidating the computed value.
Signals use referential equality by default. Replace objects and arrays instead of mutating them in place. A custom equality function can suppress equivalent writes, but it also adds comparison cost and can hide meaningful updates if its contract is too broad.
| Concern | Practical rule |
|---|---|
| Conditional dependency | Only signals read on the active branch are tracked |
| Object or array update | Return a new value from update instead of mutating the current value |
| Incidental read | Use untracked when a read must not become a dependency |
| Derived value | Use computed instead of synchronizing writable signals with an effect |
| Equivalent replacement | Use a custom equal function only after defining and testing equivalence |
signal(initialValue) creates a writable signal. You read it by calling it like a function - count(). You write to it with .set() for a direct value or .update() for a function that receives the current value.
The three operations you need to know:
| Operation | Syntax | When to use |
|---|---|---|
| Read | count() | In templates or computed/effect bodies |
| Set | count.set(5) | Replace with a known value |
| Update | count.update(v => v + 1) | Derive next value from current |
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<p>Count: {{ count() }}</p>
<button (click)="increment()">+1</button>
<button (click)="reset()">Reset</button>
`
})
export class CounterComponent {
count = signal(0);
increment() {
this.count.update(v => v + 1);
}
reset() {
this.count.set(0);
}
}
computed() creates a read-only signal whose value is derived from one or more other signals. It is lazy - it only recalculates when one of its signal dependencies actually changes, and it caches the result in between. You cannot call .set() or .update() on a computed signal.
Every time itemCount or price changes, total becomes stale and recalculates on its next read. Angular marks views that consume total; normal change detection then updates any DOM bindings whose values changed.
import { Component, signal, computed } from '@angular/core';
@Component({
selector: 'app-cart',
standalone: true,
template: `
<p>Items: {{ itemCount() }}</p>
<p>Price: ${{ price() }}</p>
<p><b>Total: ${{ total() }}</b></p>
<button (click)="addItem()">Add Item</button>
`
})
export class CartComponent {
itemCount = signal(1);
price = signal(9.99);
total = computed(() => this.itemCount() * this.price());
addItem() {
this.itemCount.update(n => n + 1);
}
}
effect registers a side effect that reruns when a signal read by its latest execution changes. Use it to synchronize reactive state with an external system such as storage, analytics, a canvas, or a non-Angular widget.
Do not use an effect to calculate state that computed or linkedSignal can express. Writing state from an effect can create circular updates and extra render passes; when an external synchronization genuinely writes a signal, make the direction and termination rule explicit.
An effect needs an injection context unless an Injector is supplied. Effects created by a component or service are destroyed with that owner; use manual cleanup only when a deliberately longer lifetime is required.
import { Component, signal, effect } from '@angular/core';
@Component({
selector: 'app-theme',
standalone: true,
template: `
<button (click)="toggleTheme()">
Theme: {{ theme() }}
</button>
`
})
export class ThemeComponent {
theme = signal('light');
constructor() {
effect(() => {
localStorage.setItem('theme', this.theme());
document.body.setAttribute('data-theme', this.theme());
});
}
toggleTheme() {
this.theme.update(t => t === 'light' ? 'dark' : 'light');
}
}
Use untracked to read a signal inside reactive code without adding it as a dependency. This is useful for incidental context such as including the current counter in a log that should rerun only when the selected user changes.
A custom equal function changes the definition of a meaningful update. Keep it deterministic and test it with the same values the UI cares about; deep comparison on large structures can cost more than the recomputation it avoids.
const selectedUser = signal<User | null>(null);
const requestCount = signal(0);
effect(() => {
const user = selectedUser();
const count = untracked(requestCount);
analytics.record('selection', { userId: user?.id, count });
});
// Changing requestCount alone does not rerun this effect.
The most powerful pattern is placing signals inside an Injectable service. The service becomes the single source of truth for a piece of state. Any component that injects the service can read the signals directly in its template - no BehaviorSubject, no async pipe, no subscriptions to manage.
Expose the internal signal as .asReadonly() so consumers cannot accidentally mutate it. Only the service's own methods can change the state.
Multiple components can inject CounterService and they all share the same signal state. When one component calls svc.increment(), every other component reading svc.count() updates automatically.
import { Injectable, signal, computed } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CounterService {
private _count = signal(0);
readonly count = this._count.asReadonly();
readonly doubled = computed(() => this._count() * 2);
readonly isZero = computed(() => this._count() === 0);
increment() { this._count.update(v => v + 1); }
decrement() { this._count.update(v => v - 1); }
reset() { this._count.set(0); }
}
import { Component, inject } from '@angular/core';
import { CounterService } from './counter.service';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<h2>Count: {{ svc.count() }}</h2>
<p>Doubled: {{ svc.doubled() }}</p>
<button (click)="svc.increment()">+</button>
<button (click)="svc.decrement()">-</button>
<button (click)="svc.reset()">Reset</button>
`
})
export class CounterComponent {
svc = inject(CounterService);
}
input() is the modern replacement for @Input(). The value is a read-only signal, so you can use it directly inside computed() and effect() without any extra wiring. Use input.required<T>() to mark an input as mandatory - Angular will throw a compile-time error if the parent does not provide it.
Because name and prefix are signals, message recomputes automatically whenever the parent changes either binding - no ngOnChanges needed.
import { Component, input, computed } from '@angular/core';
@Component({
selector: 'app-greeting',
standalone: true,
template: `<h2>{{ message() }}</h2>`
})
export class GreetingComponent {
name = input.required<string>();
prefix = input('Hello');
message = computed(() => `${this.prefix()}, ${this.name()}!`);
}
// Usage: <app-greeting name="Angular" prefix="Welcome to" />
output() replaces @Output() + EventEmitter with a simpler, more explicit API. You call .emit(value) to fire the event, and the parent listens with the same (eventName)="handler($event)" syntax it always used.
import { Component, output } from '@angular/core';
@Component({
selector: 'app-like-button',
standalone: true,
template: `<button (click)="like()">Like ({{ likeCount }})</button>`
})
export class LikeButtonComponent {
likeCount = 0;
liked = output<number>();
like() {
this.likeCount++;
this.liked.emit(this.likeCount);
}
}
// Usage: <app-like-button (liked)="onLiked($event)" />
model() combines input() and output() into a single two-way bindable signal. It is the signal-based equivalent of [(ngModel)] for custom components. The parent binds with [(propertyName)] and the child can both read and write the value.
When the child calls this.checked.update(), Angular automatically emits a checkedChange event, which the two-way binding syntax [()] uses to update the parent's variable.
import { Component, model } from '@angular/core';
@Component({
selector: 'app-toggle',
standalone: true,
template: `<button (click)="toggle()">{{ checked() ? 'ON' : 'OFF' }}</button>`
})
export class ToggleComponent {
checked = model(false);
toggle() {
this.checked.update(v => !v);
}
}
// Usage: <app-toggle [(checked)]="isActive" />
viewChild and viewChildren expose elements, directives, or child components from the component view as signals. contentChild and contentChildren do the same for projected content. The query value updates when conditional content appears, disappears, or changes.
Use required queries only when the template contract guarantees a match. Read ElementRef only when no Angular binding or directive API can express the interaction, and avoid coupling business logic to raw DOM structure.
@Component({
selector: 'app-search-box',
template: '<input #queryInput type="search">'
})
export class SearchBox {
private readonly queryInput =
viewChild.required<ElementRef<HTMLInputElement>>('queryInput');
focus(): void {
this.queryInput().nativeElement.focus();
}
}
Use linkedSignal for writable state whose valid default depends on another signal. It resets when its source changes but can still be changed by the user between source updates. This fits a selected item that must remain valid when the available options are replaced.
Use resource for asynchronous data driven by signal parameters when the resource loading contract fits the feature. It exposes status, value, error, reload, and cancellation through an AbortSignal. Use httpResource for reactive HTTP reads; keep writes in explicit HttpClient commands.
const shippingOptions = signal<readonly ShippingOption[]>([]);
const selectedOption = linkedSignal({
source: shippingOptions,
computation: (options, previous) =>
options.find(option => option.id === previous?.value.id)
?? options[0]
?? null
});
selectedOption.set(expressOption); // user choice until options change
const userId = input.required<string>();
const userResource = resource({
params: () => ({ id: userId() }),
loader: async ({ params, abortSignal }) => {
const response = await fetch('/api/users/' + params.id, {
signal: abortSignal
});
if (!response.ok) throw new Error('HTTP ' + response.status);
return await response.json() as User;
}
});
toSignal() from @angular/core/rxjs-interop bridges the RxJS world and the Signals world. It wraps an Observable and returns a Signal - no subscribe(), no async pipe, no manual unsubscribe(). Angular manages the subscription lifecycle automatically.
The signal starts as undefined until the HTTP response arrives, which is why the template checks @if (users()) before iterating. You can also pass { initialValue: [] } as a second argument to avoid the undefined state.
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';
@Component({
selector: 'app-users',
standalone: true,
template: `
@if (users()) {
@for (user of users()!; track user.id) {
<div>{{ user.name }}</div>
}
} @else {
<p>Loading...</p>
}
`
})
export class UsersComponent {
private http = inject(HttpClient);
users = toSignal(
this.http.get('https://jsonplaceholder.typicode.com/users')
);
}
Signals and RxJS are complementary, not competing. Use the right tool for the job:
A practical rule of thumb: start with Signals for all synchronous state. Reach for RxJS when you need operators like debounceTime, switchMap, or retry, then bridge back to Signals with toSignal() for the template.
| Scenario | Signals | RxJS |
|---|---|---|
| Component state (counter, toggle, form field) | ✓ | |
| Derived / computed values | ✓ | |
| HTTP requests | via toSignal() | ✓ |
| WebSocket / real-time streams | ✓ | |
| Complex async pipelines (debounce, retry, switchMap) | ✓ | |
| Shared app state (user session, cart, theme) | ✓ | |
| Template binding without async pipe | ✓ |
| API | Import | Description |
|---|---|---|
| signal(value) | @angular/core | Creates a writable signal |
| computed(() => ...) | @angular/core | Derived read-only signal, lazy & cached |
| effect(() => ...) | @angular/core | Side effect that re-runs on signal change |
| input() | @angular/core | Signal-based @Input replacement |
| input.required() | @angular/core | Required signal input (compile-time enforced) |
| output() | @angular/core | Signal-based @Output replacement |
| model() | @angular/core | Two-way bindable signal (input + output) |
| viewChild() | @angular/core | Signal-based @ViewChild |
| contentChild() | @angular/core | Signal-based @ContentChild |
| toSignal(obs$) | @angular/core/rxjs-interop | Converts an Observable to a Signal |
| toObservable(sig) | @angular/core/rxjs-interop | Converts a Signal to an Observable |
| linkedSignal(...) | @angular/core | Writable state whose default follows another signal |
| resource(...) | @angular/core | Signal-driven asynchronous loading with status and cancellation |
| httpResource(...) | @angular/common/http | Reactive HTTP read driven by signal inputs |
| untracked(...) | @angular/core | Reads a signal without recording a reactive dependency |
Angular tracks only the signals read during the latest computed execution. If a branch reads discount() only when membership() is true, discount is not a dependency while that branch is skipped. That is intentional dynamic dependency tracking.
Direct mutation bypasses the signal setter. Use user.update(current => ({ ...current, name })) so Angular receives a new value and notifies consumers. Arrays have the same issue: return a new array instead of pushing into the existing one.
Usually no. Use computed for read-only derivation and linkedSignal for writable state that resets from a source. Reserve effect for synchronization with an external system, because signal-to-signal writes can create circular updates and unnecessary render passes.
Explore 500+ free tutorials across 20+ languages and frameworks.