()` from inputs
- Never inject services, trigger routing, or cause external side effects
This contract guarantees that the component is a pure function of its inputs. It can be tested in isolation, reused across features, and rendered predictably.
Step 2: Delegate Business State to Orchestrators
Orchestrators (formerly "smart" or "container" components) own business flows. They:
- Fetch or subscribe to domain data
- Apply business rules and transformations
- Coordinate child components via inputs/outputs
- Handle routing, authentication checks, and error boundaries
- Manage feature-level state that spans multiple UI components
Orchestrators should remain lean. If an orchestrator exceeds 300 lines, extract business logic into a dedicated service or state manager.
Step 3: Leverage Signals for Local Reactivity
Signals eliminate the overhead of manual subscription management. Use them for:
- Ephemeral UI state (
signal(false), signal<string>(''))
- Derived display logic (
computed(() => ...))
- Reactive input transformation (
input() returns a signal)
Avoid using effect() for synchronous derivation. Reserve it for side effects like analytics tracking, DOM manipulation, or external API calls.
Step 4: Enforce Standalone and Change Detection Boundaries
Every component should be standalone. Pair ChangeDetectionStrategy.OnPush with signal-based inputs to guarantee Angular only re-renders when actual data changes. This eliminates unnecessary cycles and aligns with fine-grained reactivity.
Implementation Example
Presentational Component: TransactionTable
import { Component, input, output, computed, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
export interface TransactionRecord {
id: string;
merchant: string;
amount: number;
currency: string;
status: 'pending' | 'completed' | 'failed';
timestamp: string;
}
@Component({
selector: 'app-transaction-table',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<table class="transaction-table">
<thead>
<tr>
<th>Merchant</th>
<th>Amount</th>
<th>Status</th>
<th>Date</th>
</tr>
</thead>
<tbody>
@for (tx of transactions(); track tx.id) {
<tr class="transaction-row" [class.is-failed]="tx.status === 'failed'">
<td>{{ tx.merchant }}</td>
<td>{{ formattedAmount(tx) }}</td>
<td>
<span class="status-badge" [attr.data-status]="tx.status">
{{ tx.status }}
</span>
</td>
<td>{{ tx.timestamp | date:'mediumDate' }}</td>
</tr>
} @empty {
<tr>
<td colspan="4" class="empty-state">No transactions found</td>
</tr>
}
</tbody>
</table>
`,
styles: [`
.transaction-table { width: 100%; border-collapse: collapse; }
.status-badge { padding: 2px 6px; border-radius: 4px; font-size: 0.8rem; }
[data-status="failed"] { background: #fee2e2; color: #991b1b; }
[data-status="completed"] { background: #dcfce7; color: #166534; }
.empty-state { text-align: center; padding: 2rem; color: #6b7280; }
`]
})
export class TransactionTableComponent {
transactions = input.required<TransactionRecord[]>();
currencyCode = input<string>('USD');
rowSelected = output<TransactionRecord>();
formattedAmount = computed(() => {
const code = this.currencyCode();
return (tx: TransactionRecord) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: code }).format(tx.amount);
});
handleRowClick(tx: TransactionRecord): void {
this.rowSelected.emit(tx);
}
}
Orchestrator Component: TransactionOrchestrator
import { Component, signal, computed, effect, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TransactionTableComponent, TransactionRecord } from './transaction-table.component';
import { TransactionService } from './transaction.service';
@Component({
selector: 'app-transaction-orchestrator',
standalone: true,
imports: [CommonModule, TransactionTableComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="orchestrator-layout">
<header class="orchestrator-header">
<h2>Transaction History</h2>
<div class="controls">
<button (click)="refreshData()">Refresh</button>
<span class="status-indicator" [class.is-loading]="isLoading()">
{{ isLoading() ? 'Loading...' : 'Ready' }}
</span>
</div>
</header>
<app-transaction-table
[transactions]="displayData()"
(rowSelected)="handleSelection($event)"
/>
@if (selectedTransaction()) {
<aside class="detail-panel">
<h3>Transaction Details</h3>
<p>Merchant: {{ selectedTransaction()!.merchant }}</p>
<p>Status: {{ selectedTransaction()!.status }}</p>
</aside>
}
</div>
`
})
export class TransactionOrchestratorComponent {
private readonly transactionService = new TransactionService();
rawTransactions = signal<TransactionRecord[]>([]);
isLoading = signal(false);
selectedTransaction = signal<TransactionRecord | null>(null);
displayData = computed(() => {
return this.rawTransactions().sort((a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
});
constructor() {
this.fetchTransactions();
effect(() => {
const tx = this.selectedTransaction();
if (tx) {
console.debug(`[Analytics] Transaction viewed: ${tx.id}`);
}
});
}
private fetchTransactions(): void {
this.isLoading.set(true);
this.transactionService.getRecent().subscribe({
next: (data) => {
this.rawTransactions.set(data);
this.isLoading.set(false);
},
error: (err) => {
console.error('Failed to load transactions', err);
this.isLoading.set(false);
}
});
}
refreshData(): void {
this.fetchTransactions();
}
handleSelection(tx: TransactionRecord): void {
this.selectedTransaction.set(tx);
}
}
Architecture Rationale
input() as signals: Replaces @Input() with reactive primitives. Components automatically track changes without manual ngOnChanges or subscription chains.
computed() for derivation: Synchronous, cache-aware, and automatically tracked by Angular's change detection. Eliminates combineLatest and manual mapping.
- Strict output-only communication: UI components never mutate business state. They emit events. Orchestrators decide how to respond.
effect() reserved for side effects: Used only for analytics logging. All synchronous derivation uses computed().
- Standalone +
OnPush: Guarantees predictable rendering. Angular only checks components when signal inputs change.
Pitfall Guide
1. Service Injection in Presentational Components
Explanation: Injecting HttpClient, AuthService, or Router into a UI component breaks the input/output contract. The component becomes tightly coupled to infrastructure, untestable without mocks, and impossible to reuse in different contexts.
Fix: Extract data fetching and routing to the orchestrator. Pass resolved data down via input(). Emit user actions via output().
Explanation: Developers sometimes call .set() or .update() on input() signals. This violates Angular's unidirectional data flow and causes unpredictable change detection cycles.
Fix: Treat input() as read-only. If local mutation is needed, copy the input value into a local signal() or derive it with computed().
3. Overusing effect() for Synchronous Logic
Explanation: effect() runs asynchronously after change detection. Using it for data transformation or UI state calculation introduces race conditions and unnecessary renders.
Fix: Use computed() for synchronous derivation. Reserve effect() for external side effects like logging, DOM manipulation, or third-party library initialization.
4. Bypassing Outputs for Navigation
Explanation: Calling this.router.navigate() directly inside a presentational component couples UI to routing logic. It breaks testability and prevents parent components from intercepting or modifying navigation flows.
Fix: Emit an output event. Let the orchestrator handle routing, authentication guards, and query parameter construction.
5. Ignoring ChangeDetectionStrategy.OnPush
Explanation: Default change detection triggers on every async event, timer, or DOM interaction. Without OnPush, signal-based components still re-render unnecessarily, negating fine-grained reactivity benefits.
Fix: Always pair OnPush with signal-based inputs. Angular automatically optimizes detection when inputs are signals.
6. Duplicating State Across Siblings
Explanation: Two sibling components maintaining independent copies of the same data leads to synchronization bugs. Updates in one component don't reflect in the other.
Fix: Lift shared state to the common parent orchestrator or a dedicated state service. Pass derived data down via inputs.
7. Mixing Business Validation with Display Logic
Explanation: Performing currency formatting, date parsing, or business rule validation inside a presentational component bloats the template and couples UI to domain logic.
Fix: Perform validation and transformation in the orchestrator or service. Pass pre-processed data to the UI component. Keep templates declarative.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single feature, isolated UI | Presentational component + local orchestrator | Minimal coordination, fast iteration, easy testing | Low |
| Cross-feature shared data | Dedicated state service with signal() | Single source of truth, avoids duplication, predictable updates | Medium |
| Complex business flows | Orchestrator + domain service + presentational layer | Separates concerns, enables parallel development, simplifies debugging | Medium-High |
| Real-time streaming data | Service with resource() or WebSocket + orchestrator subscription | Handles async boundaries, prevents memory leaks, aligns with Angular 18+ patterns | High |
| Design system components | Pure presentational, zero services, strict inputs/outputs | Maximum reusability, framework-agnostic potential, zero infrastructure coupling | Low |
Configuration Template
Copy this baseline for new Angular features enforcing boundary contracts:
// feature-name.component.ts (Orchestrator)
import { Component, signal, computed, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FeatureUiComponent } from './feature-ui.component';
import { FeatureService } from './feature.service';
@Component({
selector: 'app-feature-orchestrator',
standalone: true,
imports: [CommonModule, FeatureUiComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<app-feature-ui
[data]="processedData()"
[isLoading]="loadingState()"
(actionTriggered)="handleAction($event)"
/>
`
})
export class FeatureOrchestratorComponent {
private readonly service = new FeatureService();
rawState = signal<any[]>([]);
loadingState = signal(false);
processedData = computed(() => {
return this.rawState().map(item => ({
...item,
formatted: this.formatItem(item)
}));
});
constructor() {
this.loadData();
}
private loadData(): void {
this.loadingState.set(true);
this.service.fetch().subscribe({
next: (res) => { this.rawState.set(res); this.loadingState.set(false); },
error: () => this.loadingState.set(false)
});
}
handleAction(payload: any): void {
// Route, validate, or delegate to service
}
private formatItem(item: any): string {
return `${item.id}-${item.name}`;
}
}
Quick Start Guide
- Identify boundary violations: Search your codebase for
@Injectable() usage inside components labeled as presentational. Flag components that call router.navigate() or inject HttpClient.
- Extract orchestration logic: Create a new standalone component to own data fetching, transformation, and routing. Move service injections and business rules there.
- Convert inputs/outputs: Replace
@Input()/@Output() with input()/output(). Update templates to use signal invocation syntax input().
- Enforce
OnPush: Add changeDetection: ChangeDetectionStrategy.OnPush to every component. Verify that Angular only re-renders when signal inputs change.
- Validate with tests: Write unit tests for presentational components using only input data. Confirm zero service mocks are required. Run performance audits to verify reduced change detection cycles.