Angular has undergone a remarkable transformation in recent years. What was once considered a heavyweight, opinionated framework has evolved into a streamlined, developer-friendly platform that rivals its competitors in both simplicity and performance. At the heart of this evolution are two groundbreaking features: Signals and Standalone Components. Together, they represent the most significant shift in Angular development since the original rewrite from AngularJS.
If you’ve been working with Angular for a while, or if you’re coming from frameworks like React or Vue (check out our React vs Vue vs Svelte comparison for context), this guide will walk you through everything you need to know about building modern Angular applications with signals-based reactivity and standalone architecture.
What Are Angular Signals?
Angular Signals, introduced in Angular 16 and refined through subsequent releases, are a fine-grained reactivity primitive that fundamentally changes how Angular tracks and propagates state changes. Unlike the traditional Zone.js-based change detection that checked the entire component tree, signals allow Angular to know exactly which parts of the UI need to update when data changes.
A signal is essentially a wrapper around a value that notifies interested consumers when that value changes. Think of it as a reactive variable — when you update the signal’s value, anything that depends on it automatically recalculates or re-renders.
Why Signals Matter
Before signals, Angular relied on Zone.js to intercept asynchronous operations and trigger change detection across the entire component tree. While this approach worked, it had significant drawbacks:
- Performance overhead: Every async operation (click events, HTTP requests, timers) triggered a full change detection cycle across all components
- Unpredictable rendering: Developers often struggled to understand when and why change detection ran
- Complex optimization: Strategies like
OnPushchange detection and manualChangeDetectorRefmanagement added cognitive burden - Bundle size: Zone.js itself added roughly 13KB to every Angular application
Signals solve all of these problems by providing a pull-based reactivity model. The framework knows precisely which signals have changed and which template bindings depend on them, enabling surgical DOM updates without scanning the entire component tree. This approach dramatically improves web performance for complex applications.
Signals in Practice: Core API
The Signals API consists of three primary primitives that work together to create a complete reactivity system:
1. Writable Signals
A writable signal holds a value that you can read and update. You create one using the signal() function:
import { signal } from '@angular/core';
// Create a writable signal with an initial value
const count = signal(0);
// Read the value by calling the signal as a function
console.log(count()); // 0
// Set a new value
count.set(5);
// Update based on the previous value
count.update(prev => prev + 1);
2. Computed Signals
Computed signals derive their value from other signals. They are read-only and automatically recalculate when any of their dependencies change:
import { signal, computed } from '@angular/core';
const price = signal(29.99);
const quantity = signal(3);
// Automatically recalculates when price or quantity changes
const total = computed(() => price() * quantity());
console.log(total()); // 89.97
quantity.set(5);
console.log(total()); // 149.95
Computed signals use lazy evaluation — they only recalculate when their value is actually read, and they cache the result until a dependency changes. This makes them extremely efficient for derived state.
3. Effects
Effects are operations that run whenever the signals they read change. They are primarily used for side effects like logging, synchronizing with external systems, or interacting with APIs that aren’t part of Angular’s rendering pipeline:
import { signal, effect } from '@angular/core';
const searchQuery = signal('');
effect(() => {
console.log(`Search query changed to: ${searchQuery()}`);
// Sync with analytics, local storage, etc.
});
Complete Signal-Based Component Example
Let’s build a real-world shopping cart component that demonstrates signals, computed values, and effects working together. This example showcases the patterns you’ll use daily in modern Angular development:
import { Component, signal, computed, effect } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
@Component({
selector: 'app-shopping-cart',
standalone: true,
imports: [CurrencyPipe],
template: `
<div class="cart-container">
<h2>Shopping Cart ({{ itemCount() }} items)</h2>
@if (items().length === 0) {
<p class="empty-state">Your cart is empty.</p>
} @else {
<ul class="cart-items">
@for (item of items(); track item.id) {
<li class="cart-item">
<span class="item-name">{{ item.name }}</span>
<div class="quantity-controls">
<button (click)="decrementQuantity(item.id)"
[disabled]="item.quantity <= 1">
−
</button>
<span class="quantity">{{ item.quantity }}</span>
<button (click)="incrementQuantity(item.id)">+</button>
</div>
<span class="item-total">
{{ item.price * item.quantity | currency }}
</span>
<button class="remove-btn"
(click)="removeItem(item.id)">
Remove
</button>
</li>
}
</ul>
<div class="cart-summary">
@if (discount() > 0) {
<p class="discount">
Discount: -{{ discount() | currency }}
</p>
}
<p class="subtotal">
Subtotal: {{ subtotal() | currency }}
</p>
<p class="tax">
Tax (8%): {{ tax() | currency }}
</p>
<p class="grand-total">
Total: {{ grandTotal() | currency }}
</p>
<button class="checkout-btn"
(click)="checkout()">
Proceed to Checkout
</button>
</div>
}
</div>
`
})
export class ShoppingCartComponent {
// Writable signals — the source of truth
items = signal<CartItem[]>([
{ id: 1, name: 'Wireless Keyboard', price: 79.99, quantity: 1 },
{ id: 2, name: 'USB-C Hub', price: 49.99, quantity: 2 },
{ id: 3, name: 'Monitor Stand', price: 129.99, quantity: 1 }
]);
discountCode = signal<string | null>(null);
// Computed signals — derived state
itemCount = computed(() =>
this.items().reduce((sum, item) => sum + item.quantity, 0)
);
subtotal = computed(() =>
this.items().reduce(
(sum, item) => sum + item.price * item.quantity, 0
)
);
discount = computed(() => {
if (this.discountCode() === 'SAVE20') {
return this.subtotal() * 0.2;
}
return 0;
});
tax = computed(() =>
(this.subtotal() - this.discount()) * 0.08
);
grandTotal = computed(() =>
this.subtotal() - this.discount() + this.tax()
);
constructor() {
// Effect — side effect that runs when
// dependent signals change
effect(() => {
const total = this.grandTotal();
const count = this.itemCount();
// Sync cart state to localStorage
localStorage.setItem('cart-summary', JSON.stringify({
itemCount: count,
total: total,
lastUpdated: new Date().toISOString()
}));
});
}
incrementQuantity(id: number): void {
this.items.update(items =>
items.map(item =>
item.id === id
? { ...item, quantity: item.quantity + 1 }
: item
)
);
}
decrementQuantity(id: number): void {
this.items.update(items =>
items.map(item =>
item.id === id && item.quantity > 1
? { ...item, quantity: item.quantity - 1 }
: item
)
);
}
removeItem(id: number): void {
this.items.update(items =>
items.filter(item => item.id !== id)
);
}
checkout(): void {
console.log('Processing checkout...', {
items: this.items(),
total: this.grandTotal()
});
}
}
Notice several important patterns in this example. The items signal is the single source of truth — every computed signal derives from it. When you call incrementQuantity(), only the signals that actually depend on the changed item recalculate. The effect automatically syncs to localStorage whenever the cart total or item count changes, without manual subscription management.
Understanding Standalone Components
Standalone Components represent Angular’s move away from the NgModule system that has been a source of confusion since Angular 2. Introduced in Angular 14 and made the default in Angular 17+, standalone components are self-contained units that declare their own dependencies directly.
The Problem with NgModules
In traditional Angular, every component had to belong to an NgModule. This created several pain points:
- Boilerplate overload: Every new feature required creating or modifying a module file
- Confusing dependency resolution: Understanding which module provided which directive or pipe was often unclear
- Circular dependencies: Complex module hierarchies frequently led to circular import issues
- Learning curve: New developers (especially those coming from Vue or React) found the module system unnecessarily complex
How Standalone Components Work
A standalone component declares standalone: true in its decorator and lists its dependencies directly in the imports array. No NgModule required:
@Component({
selector: 'app-user-profile',
standalone: true,
imports: [CommonModule, RouterLink, DatePipe],
template: `...`
})
export class UserProfileComponent { }
This approach makes components truly portable — you can use them anywhere without worrying about module configurations. It also enables better tree-shaking, since the compiler can trace exact dependency graphs for each component.
New Control Flow Syntax
Angular 17 introduced a new built-in control flow syntax that replaces the traditional structural directives (*ngIf, *ngFor, *ngSwitch). This new syntax uses block-based templates that are more readable and performant:
@if / @else
@if (user()) {
<h1>Welcome, {{ user().name }}!</h1>
} @else {
<h1>Please sign in</h1>
}
@for with track
@for (item of items(); track item.id) {
<app-item-card [item]="item" />
} @empty {
<p>No items found.</p>
}
@switch
@switch (status()) {
@case ('loading') { <app-spinner /> }
@case ('error') { <app-error [message]="errorMsg()" /> }
@case ('success') { <app-content [data]="data()" /> }
@default { <p>Unknown state</p> }
}
The new control flow syntax offers several advantages over structural directives: it’s checked at compile time for type safety, it doesn’t require importing CommonModule, and the @for block requires a track expression which prevents the common performance pitfall of forgetting trackBy in *ngFor.
Building a Complete Standalone Application
Let’s build a more comprehensive example — a task dashboard component that demonstrates standalone architecture, signals, the new control flow, and modern Angular patterns together. This is the kind of component you might build when integrating a project management tool like Taskee into your workflow:
import {
Component, signal, computed, effect,
inject, OnInit
} from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { RouterLink } from '@angular/router';
import { DatePipe } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { toSignal } from '@angular/core/rxjs-interop';
import { Subject, debounceTime, switchMap } from 'rxjs';
interface Task {
id: string;
title: string;
status: 'todo' | 'in-progress' | 'done';
priority: 'low' | 'medium' | 'high';
assignee: string;
dueDate: string;
}
@Component({
selector: 'app-task-dashboard',
standalone: true,
imports: [RouterLink, DatePipe, FormsModule],
template: `
<div class="dashboard">
<header class="dashboard-header">
<h1>Task Dashboard</h1>
<div class="filters">
<input
type="text"
placeholder="Search tasks..."
[ngModel]="searchTerm()"
(ngModelChange)="onSearch($event)"
/>
<select
[ngModel]="filterStatus()"
(ngModelChange)="filterStatus.set($event)">
<option value="all">All Statuses</option>
<option value="todo">To Do</option>
<option value="in-progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
</header>
<div class="stats-bar">
<div class="stat">
<span class="stat-value">{{ stats().total }}</span>
<span class="stat-label">Total</span>
</div>
<div class="stat">
<span class="stat-value">{{ stats().completed }}</span>
<span class="stat-label">Completed</span>
</div>
<div class="stat">
<span class="stat-value">
{{ stats().completionRate }}%
</span>
<span class="stat-label">Progress</span>
</div>
<div class="stat">
<span class="stat-value">{{ stats().overdue }}</span>
<span class="stat-label">Overdue</span>
</div>
</div>
@if (loading()) {
<div class="loading-state">
<p>Loading tasks...</p>
</div>
} @else if (filteredTasks().length === 0) {
<div class="empty-state">
<p>No tasks match your criteria.</p>
<button (click)="resetFilters()">
Clear Filters
</button>
</div>
} @else {
<div class="task-columns">
@for (
column of columns; track column.status
) {
<div class="column">
<h3>
{{ column.label }}
({{ getColumnTasks(column.status).length }})
</h3>
@for (
task of getColumnTasks(column.status);
track task.id
) {
<div class="task-card"
[class]="'priority-' + task.priority">
<h4>{{ task.title }}</h4>
<div class="task-meta">
<span class="assignee">
{{ task.assignee }}
</span>
<span class="due-date">
{{ task.dueDate | date:'mediumDate' }}
</span>
</div>
<div class="task-actions">
@switch (task.status) {
@case ('todo') {
<button
(click)="moveTask(
task.id, 'in-progress'
)">
Start
</button>
}
@case ('in-progress') {
<button
(click)="moveTask(
task.id, 'done'
)">
Complete
</button>
}
@case ('done') {
<button
(click)="moveTask(
task.id, 'todo'
)">
Reopen
</button>
}
}
</div>
</div>
}
</div>
}
</div>
}
</div>
`
})
export class TaskDashboardComponent implements OnInit {
private http = inject(HttpClient);
// State signals
tasks = signal<Task[]>([]);
loading = signal(true);
searchTerm = signal('');
filterStatus = signal<string>('all');
// Column configuration
columns = [
{ status: 'todo' as const, label: 'To Do' },
{ status: 'in-progress' as const, label: 'In Progress' },
{ status: 'done' as const, label: 'Done' }
];
// Search with debounce via RxJS interop
private searchSubject = new Subject<string>();
// Computed: filtered tasks
filteredTasks = computed(() => {
let result = this.tasks();
const status = this.filterStatus();
const search = this.searchTerm().toLowerCase();
if (status !== 'all') {
result = result.filter(t => t.status === status);
}
if (search) {
result = result.filter(t =>
t.title.toLowerCase().includes(search) ||
t.assignee.toLowerCase().includes(search)
);
}
return result;
});
// Computed: dashboard statistics
stats = computed(() => {
const all = this.tasks();
const completed = all.filter(
t => t.status === 'done'
).length;
const overdue = all.filter(t =>
t.status !== 'done' &&
new Date(t.dueDate) < new Date()
).length;
return {
total: all.length,
completed,
completionRate: all.length
? Math.round((completed / all.length) * 100)
: 0,
overdue
};
});
constructor() {
// Effect: log stats changes for monitoring
effect(() => {
const s = this.stats();
if (s.overdue > 0) {
console.warn(
`${s.overdue} tasks are overdue!`
);
}
});
}
ngOnInit(): void {
this.http.get<Task[]>('/api/tasks').subscribe({
next: (tasks) => {
this.tasks.set(tasks);
this.loading.set(false);
},
error: () => this.loading.set(false)
});
}
onSearch(term: string): void {
this.searchTerm.set(term);
}
getColumnTasks(
status: Task['status']
): Task[] {
return this.filteredTasks().filter(
t => t.status === status
);
}
moveTask(
id: string,
newStatus: Task['status']
): void {
this.tasks.update(tasks =>
tasks.map(t =>
t.id === id ? { ...t, status: newStatus } : t
)
);
}
resetFilters(): void {
this.searchTerm.set('');
this.filterStatus.set('all');
}
}
This component demonstrates several key patterns: dependency injection with inject() instead of constructor injection, the RxJS-to-Signal interop bridge via toSignal(), computed signals for derived state like statistics and filtered results, and the new control flow syntax throughout the template.
Signal Inputs and Model Inputs
Angular 17.1+ introduced signal-based inputs that replace the traditional @Input() decorator with a signals-native API:
import { Component, input, model } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
template: `
<button (click)="decrement()">-</button>
<span>{{ value() }}</span>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
// Read-only signal input
label = input<string>('Counter');
// Required input
min = input.required<number>();
// Two-way bindable model input
value = model(0);
increment() { this.value.update(v => v + 1); }
decrement() {
this.value.update(v => Math.max(this.min(), v - 1));
}
}
The parent component can now use two-way binding with the model signal:
<app-counter [(value)]="myCount" [min]="0" />
This eliminates the @Input() / @Output() boilerplate pattern that was previously required for two-way binding and brings Angular’s component communication closer to what developers expect from modern frameworks.
Migrating from Zone.js to Signals
If you have an existing Angular application, migrating to signals doesn’t have to happen all at once. Angular provides excellent coexistence between the old and new patterns:
Step 1: Start with New Components
Write all new components as standalone with signals. Existing NgModule-based components will continue to work alongside them without any issues.
Step 2: Use the RxJS Interop
Angular’s @angular/core/rxjs-interop package provides toSignal() and toObservable() to bridge between RxJS Observables and Signals. This is crucial for services that use HttpClient or other RxJS-based APIs:
import { toSignal } from '@angular/core/rxjs-interop';
import { interval } from 'rxjs';
// Convert an Observable to a Signal
const counter = toSignal(interval(1000), { initialValue: 0 });
Step 3: Enable Zoneless Mode
Once your application is fully using signals for state management, you can remove Zone.js entirely for maximum performance. In Angular 18+, experimental zoneless change detection is available:
// main.ts
bootstrapApplication(AppComponent, {
providers: [
provideExperimentalZonelessChangeDetection()
]
});
This single change can reduce your bundle size and improve runtime performance significantly, since the framework no longer needs to monkey-patch browser APIs.
Standalone Component Architecture Patterns
With NgModules fading into the background, new architectural patterns have emerged. Here are the ones that work best in production:
Lazy Loading with Standalone Components
Route-level code splitting becomes simpler with standalone components. You can lazy-load individual components directly in the router configuration:
export const routes: Routes = [
{
path: 'dashboard',
loadComponent: () =>
import('./dashboard/dashboard.component')
.then(m => m.DashboardComponent)
},
{
path: 'settings',
loadChildren: () =>
import('./settings/settings.routes')
.then(m => m.settingsRoutes)
}
];
This approach gives you fine-grained control over bundle splitting, which is essential for optimizing rendering strategies whether you’re using SSR or CSR.
Service Architecture with inject()
The inject() function, which works in standalone components, enables more flexible dependency injection patterns. Services can now use signals internally to provide reactive state:
@Injectable({ providedIn: 'root' })
export class AuthService {
private currentUser = signal<User | null>(null);
// Expose read-only signals to consumers
user = this.currentUser.asReadonly();
isAuthenticated = computed(() => this.currentUser() !== null);
login(credentials: LoginRequest): void {
// After successful auth...
this.currentUser.set(userData);
}
logout(): void {
this.currentUser.set(null);
}
}
Testing Signal-Based Components
Testing signals is straightforward because they are synchronous by nature. Unlike Observable-based testing that often requires async utilities, signal values are immediately available. If you’re setting up end-to-end testing with Playwright, signal-based components behave predictably since state changes are synchronous and deterministic:
describe('ShoppingCartComponent', () => {
it('should calculate the correct total', () => {
const fixture = TestBed.createComponent(
ShoppingCartComponent
);
const component = fixture.componentInstance;
component.items.set([
{ id: 1, name: 'Item', price: 10, quantity: 3 }
]);
// Signal values are immediately available
expect(component.subtotal()).toBe(30);
expect(component.itemCount()).toBe(3);
});
});
Angular Signals vs Other Frameworks’ Reactivity
If you’re evaluating Angular’s new reactivity model against other frameworks (see our comprehensive best web frameworks for 2026 guide), here’s how signals compare:
- vs React hooks: Signals are more granular than
useState. React re-renders entire components on state changes; Angular with signals updates only the affected DOM bindings - vs Vue refs: Very similar in concept. Vue’s
ref()andcomputed()inspired Angular’s API. The main difference is Angular signals don’t auto-unwrap in templates — you callcount()instead of justcount - vs Svelte runes: Svelte’s
$stateand$derivedserve the same purpose but use compiler magic. Angular signals are runtime primitives with explicit function calls - vs SolidJS signals: Angular signals are directly inspired by SolidJS. The API is nearly identical, though Angular adds framework-specific features like signal inputs and the Zone.js interop layer
For teams building complex web applications, Angular’s signal system combined with its robust ecosystem of tools, TypeScript-first development, and enterprise-grade features makes it a compelling choice. Organizations looking to build professional web solutions with agencies like Toimi often appreciate Angular’s structured approach to large-scale application development.
Performance Best Practices
To get the most out of signals and standalone components, follow these guidelines:
- Keep signals granular: Instead of one large state signal, use multiple fine-grained signals. This allows Angular to track changes more precisely
- Use computed for derived state: Never recalculate derived values in templates. Use
computed()to memoize and share derived state efficiently - Avoid effects for state synchronization: Effects should handle side effects (logging, analytics, DOM manipulation), not state derivation. Use
computed()for state-to-state transformations - Leverage signal inputs: Replace
@Input()withinput()to enable signal-based change propagation from parent to child components - Tree-shake aggressively: Standalone components enable better tree-shaking. Import only what each component needs, not entire modules
The Road Ahead: What’s Coming Next
Angular’s investment in signals and standalone components is just the beginning. The framework roadmap includes several exciting developments that continue this modernization trajectory:
- Stable zoneless mode: Full production-ready support for applications without Zone.js, potentially making it the default in future Angular versions
- Signal-based forms: A reimagined forms API built on signals, replacing both template-driven and reactive forms with a unified, simpler approach
- Improved SSR hydration: Partial hydration and event replay, leveraging signals for more efficient server-to-client handoff
- Resource API: A signal-native way to handle async data fetching, replacing common patterns involving
toSignal()with HTTP observables
The evolution from the original AngularJS framework to the modern signal-based Angular is remarkable. What was once a monolithic MVC framework has become a lean, reactive platform that competes head-to-head with any modern framework on developer experience and performance.
Frequently Asked Questions
Can I use Angular Signals without removing Zone.js?
Yes, absolutely. Signals and Zone.js coexist perfectly well. You can adopt signals incrementally in your existing Angular application without removing Zone.js. The framework’s change detection will use both mechanisms simultaneously — Zone.js for components that haven’t been migrated, and signal-based tracking for those that have. Removing Zone.js is an optional optimization you can pursue once your entire application uses signals for state management.
Do standalone components replace NgModules entirely?
Standalone components are the recommended approach for all new Angular development as of Angular 17+. However, NgModules are not deprecated and will continue to work for the foreseeable future. The Angular team has committed to backward compatibility, so existing NgModule-based applications will keep functioning. For new projects, standalone components are the default, and the Angular CLI no longer generates NgModules by default. You can also mix standalone components and NgModule-based components in the same application.
How do Angular Signals compare to RxJS Observables?
Signals and Observables serve different purposes and complement each other. Signals are ideal for synchronous, state-based reactivity — tracking values that change over time and deriving new values from them. Observables excel at handling asynchronous event streams, complex async composition (debounce, switchMap, retry), and scenarios involving backpressure. Angular provides the toSignal() and toObservable() functions to bridge between the two systems. A common pattern is using Observables for HTTP calls and event streams, then converting the results to signals for template consumption.
What is the minimum Angular version required for Signals and Standalone Components?
Standalone Components were introduced as a developer preview in Angular 14 and became stable in Angular 15. Signals arrived as a developer preview in Angular 16 and reached stability in Angular 17. The new built-in control flow syntax (@if, @for, @switch) was introduced in Angular 17. Signal-based inputs and model inputs became available in Angular 17.1. For the best experience with all modern features, Angular 17 or later is recommended, with Angular 18+ offering additional improvements like experimental zoneless support.
Is it worth migrating an existing Angular app to Signals and Standalone Components?
For most production applications, a gradual migration is worthwhile. The benefits include better performance through fine-grained reactivity, reduced bundle size (especially when Zone.js is eventually removed), simpler component architecture without NgModules, and improved developer experience. Angular provides automated migration schematics (ng generate @angular/core:standalone) that can convert NgModule-based components to standalone. The key is to migrate incrementally — start with leaf components, then work your way up the component tree. New features should always be written with signals and standalone components from the start.