string;
readonly label: string;
readonly warehouseZone: string;
readonly quantityStatus: 'critical' | 'standard' | 'excess';
readonly lastRestocked: string;
readonly isReserved: boolean;
readonly actionPermissions: {
canTransfer: boolean;
canAdjust: boolean;
};
}
### Step 2: Implement Reactive Derivation
Use `computed()` to transform raw domain data into the ViewModel. The computation runs only when tracked signals change, and the result is memoized.
```typescript
import { Component, computed, signal, inject } from '@angular/core';
import { InventoryService } from './inventory.service';
import { StockItem, WarehouseZone } from './inventory.models';
@Component({
selector: 'app-inventory-dashboard',
standalone: true,
template: `
@if (stockViewModels().length) {
<table class="inventory-grid">
@for (item of stockViewModels(); track item.sku) {
<tr [class.critical]="item.quantityStatus === 'critical'">
<td>{{ item.label }}</td>
<td>{{ item.warehouseZone }}</td>
<td>{{ item.quantityStatus | uppercase }}</td>
<td>{{ item.lastRestocked }}</td>
<td>
<button [disabled]="!item.actionPermissions.canTransfer">Transfer</button>
<button [disabled]="!item.actionPermissions.canAdjust">Adjust</button>
</td>
</tr>
}
</table>
} @else {
<p class="empty-notice">No inventory records match current filters.</p>
}
`
})
export class InventoryDashboardComponent {
private readonly inventoryService = inject(InventoryService);
readonly rawStock = signal<StockItem[]>([]);
readonly activeZone = signal<WarehouseZone | null>(null);
readonly minThreshold = signal<number>(10);
readonly currentUserRole = signal<'manager' | 'operator' | 'viewer'>('viewer');
readonly stockViewModels = computed<StockItemViewModel[]>(() => {
const zoneFilter = this.activeZone();
const threshold = this.minThreshold();
const role = this.currentUserRole();
return this.rawStock()
.filter(item => zoneFilter ? item.zone === zoneFilter : true)
.map(item => {
const qty = item.quantity;
const status: StockItemViewModel['quantityStatus'] =
qty < threshold ? 'critical' : qty > threshold * 3 ? 'excess' : 'standard';
return {
sku: item.sku,
label: `${item.category} - ${item.name}`,
warehouseZone: item.zone,
quantityStatus: status,
lastRestocked: this.formatDate(item.lastRestockDate),
isReserved: item.reservationId !== null,
actionPermissions: {
canTransfer: role === 'manager' || (role === 'operator' && !item.reserved),
canAdjust: role === 'manager'
}
};
});
});
private formatDate(date: Date): string {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
}
Step 3: Manage Asynchronous State Declaratively
Angular 19.1+ introduces resource() for reactive data fetching. It replaces async pipes and manual subscription management with a unified state object.
import { resource } from '@angular/core';
// Inside the component class:
readonly inventoryResource = resource({
request: () => ({
zone: this.activeZone(),
threshold: this.minThreshold()
}),
loader: ({ request }) =>
this.inventoryService.fetchStock(request.zone, request.threshold)
});
// Sync raw signal with resource value
effect(() => {
const data = this.inventoryResource.value();
if (data) {
this.rawStock.set(data);
}
});
Architecture Rationale
- Explicit Dependency Tracking:
computed() registers signal reads during execution. Angular's runtime builds a dependency graph, ensuring recomputation occurs only when activeZone, minThreshold, or rawStock actually change.
- Structural Sharing: The
map() operation creates new ViewModel objects, but the reference equality of unchanged items prevents unnecessary DOM updates when combined with track item.sku.
- Separation of Concerns: The template contains zero business rules. It receives pre-calculated state and renders it. This aligns with the Single Responsibility Principle and enables parallel development (UI designers work on templates, engineers work on derivation logic).
- Change Detection Alignment: With
OnPush (default for standalone components), Angular only checks the component when input references change or events fire. Signal-derived state respects this boundary, preventing cascade re-evaluations across the tree.
Pitfall Guide
1. Overloading computed() with Side Effects
Explanation: Developers sometimes place HTTP calls, DOM manipulation, or state mutations inside computed(). This breaks memoization guarantees and causes unpredictable execution order.
Fix: computed() must remain pure. Use effect() for side effects, or delegate async operations to services and resource().
2. Omitting track in @for Loops
Explanation: Without a stable tracking expression, Angular falls back to index-based diffing. This triggers full DOM reconstruction on array mutations, causing layout thrashing and lost focus states.
Fix: Always provide a unique identifier: @for (item of list(); track item.id). If no ID exists, generate a deterministic hash during ViewModel preparation.
3. Mixing AsyncPipe with Signals in the Same Component
Explanation: Combining | async with computed() creates dual reactivity pipelines. Change detection may fire twice per update cycle, and error/loading states become desynchronized.
Fix: Standardize on one paradigm per component. For new code, prefer resource() + signals. For legacy migrations, isolate async pipes to dedicated wrapper components.
4. Mutating Signals Inside Template Bindings
Explanation: Writing (click)="selectedItems().push(item)" or similar mutations in template expressions bypasses Angular's signal update validation. This can cause infinite loops or silent state corruption.
Fix: Always use .set(), .update(), or dedicated component methods. Keep template bindings strictly declarative.
5. Ignoring OnPush Boundaries with Nested Signals
Explanation: When a parent component passes a signal to a child, the child may not detect updates if it only watches the signal reference rather than its value.
Fix: In child components, read the signal value explicitly in the template or computed(). Avoid passing raw signals as inputs unless using input() with transformation.
6. Creating Circular Signal Dependencies
Explanation: If Signal A depends on Signal B, and Signal B depends on Signal A, Angular throws a Maximum call stack size exceeded error during the first read.
Fix: Enforce unidirectional data flow. Use effect() to synchronize state when bidirectional updates are unavoidable, or restructure the dependency graph into a DAG (Directed Acyclic Graph).
7. Premature Optimization with computed()
Explanation: Wrapping every variable in computed() adds runtime overhead. Simple static values or one-time calculations don't benefit from memoization.
Fix: Reserve computed() for derived state that depends on multiple signals, requires expensive transformation, or feeds into multiple template bindings. Use plain properties for static configuration.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
Simple static binding (e.g., {{ title }}) | Direct property or input() | Zero overhead, native change detection | Negligible |
| Derived state from 2+ signals | computed() | Memoization prevents redundant calculations | Low (runtime tracking) |
| Async data with loading/error states | resource() | Unified state machine, automatic cancellation | Medium (Angular runtime) |
| Complex formatting across multiple components | Pure utility function + computed() | Reusability without pipe registration overhead | Low |
| Real-time streaming data (WebSockets) | signal() + effect() | Explicit update control, avoids subscription leaks | Medium |
| Legacy module-based components | AsyncPipe + OnPush | Migration compatibility, lower refactoring cost | High (technical debt) |
Configuration Template
import { Component, computed, resource, signal, effect, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
// Domain Models
export interface RawRecord {
id: string;
category: string;
value: number;
timestamp: Date;
metadata: Record<string, unknown>;
}
// Presentation Contract
export interface DisplayViewModel {
readonly id: string;
readonly formattedValue: string;
readonly categoryLabel: string;
readonly ageIndicator: 'new' | 'recent' | 'stale';
readonly isEditable: boolean;
}
@Component({
selector: 'app-data-presenter',
standalone: true,
imports: [CommonModule],
template: `
@switch (dataResource.status()) {
@case ('loading') { <div class="skeleton">Loading records...</div> }
@case ('error') { <div class="error">Failed to fetch data. Retry?</div> }
@case ('resolved') {
<ul class="record-list">
@for (item of processedView(); track item.id) {
<li [class.stale]="item.ageIndicator === 'stale'">
<span class="label">{{ item.categoryLabel }}</span>
<span class="value">{{ item.formattedValue }}</span>
<button [disabled]="!item.isEditable">Edit</button>
</li>
}
</ul>
}
}
`,
styles: [`
.skeleton { padding: 1rem; background: #f0f0f0; border-radius: 4px; }
.error { color: #d32f2f; padding: 1rem; }
.record-list { list-style: none; padding: 0; }
.record-list li { display: flex; justify-content: space-between; padding: 0.5rem; border-bottom: 1px solid #eee; }
.stale { opacity: 0.6; }
`]
})
export class DataPresenterComponent {
private readonly api = inject(DataApiService);
readonly filterCategory = signal<string | null>(null);
readonly editMode = signal<boolean>(false);
readonly dataResource = resource({
request: () => ({ category: this.filterCategory() }),
loader: ({ request }) => this.api.fetchRecords(request.category)
});
readonly processedView = computed<DisplayViewModel[]>(() => {
const records = this.dataResource.value() ?? [];
const editAllowed = this.editMode();
const now = Date.now();
return records
.filter(r => r.value > 0)
.map(r => ({
id: r.id,
formattedValue: `$${r.value.toFixed(2)}`,
categoryLabel: r.category.toUpperCase(),
ageIndicator: this.calculateAge(now, r.timestamp),
isEditable: editAllowed && r.metadata.approved === true
}));
});
private calculateAge(current: number, timestamp: Date): DisplayViewModel['ageIndicator'] {
const diffHours = (current - timestamp.getTime()) / 3600000;
if (diffHours < 24) return 'new';
if (diffHours < 168) return 'recent';
return 'stale';
}
}
// Mock service for template completeness
class DataApiService {
fetchRecords(category: string | null) {
return Promise.resolve<RawRecord[]>([]);
}
}
Quick Start Guide
- Install Angular 19.1+: Ensure your project uses a version that supports
resource() and stable Signals. Run ng update @angular/core @angular/cli if necessary.
- Identify a Candidate Component: Select a component with at least two template method calls or complex
*ngIf/*ngFor logic. Export its current template to a backup file.
- Extract Transformation Logic: Move all filtering, sorting, and formatting into a
computed() signal. Define a corresponding ViewModel interface that matches the template's binding requirements.
- Replace Template Bindings: Swap method calls with signal reads. Update
@for loops to include track expressions. Remove inline pipes and conditional calculations.
- Validate & Profile: Run
ng test to verify unit coverage. Open Angular DevTools, trigger interactions, and confirm that change detection cycles decrease and template execution time stabilizes.