Angular v21: Complete Guide to New Features and Advanced Techniques
Deep dive into Angular v21 new features including Signal Forms, Zoneless by default, Angular Aria, Vitest stable, MCP Server for AI, and advanced techniques for modern Angular development.
Angular v21, released on November 20, 2025, delivers modern AI tooling, performance updates, and fantastic new features to improve developer experience. Whether you code using agents and AI assistance or prefer to write code with just you and your IDE, Angular v21 has you covered.
Table of Contents
- Signal Forms (Experimental)
- Angular Aria - Accessible Components
- Zoneless is Now Default
- Vitest as Default Test Runner
- MCP Server for AI Agents
- Template Improvements
- Advanced Techniques
- Migration Guide
Signal Forms (Experimental)
The most anticipated feature in Angular v21 is Signal Forms - a new experimental library that allows you to manage form state by building on the reactive foundations of Signals!
Basic Signal Forms Usage
import { Component, signal } from '@angular/core';
import { form, Field } from '@angular/forms/signals';
@Component({
selector: 'app-login',
imports: [Field],
template: `
<form (submit)="onSubmit()">
Email: <input [field]="loginForm.email">
Password: <input type="password" [field]="loginForm.password">
<button type="submit">Login</button>
</form>
`
})
export class LoginComponent {
login = signal({
email: '',
password: ''
});
loginForm = form(this.login);
onSubmit() {
console.log('Form values:', this.login());
}
}
Built-in Validation
Signal Forms comes with powerful and centralized schema-based validation:
import { Component, signal } from '@angular/core';
import { form, Field, validators } from '@angular/forms/signals';
@Component({
selector: 'app-registration',
imports: [Field],
template: `
<form (submit)="onSubmit()">
<div>
<label>Email:</label>
<input [field]="regForm.email">
@if (regForm.email.errors()?.email) {
<span class="error">Invalid email format</span>
}
</div>
<div>
<label>Password:</label>
<input type="password" [field]="regForm.password">
@if (regForm.password.errors()?.minLength) {
<span class="error">Password must be at least 8 characters</span>
}
</div>
<div>
<label>Age:</label>
<input type="number" [field]="regForm.age">
@if (regForm.age.errors()?.pattern) {
<span class="error">Please enter a valid age</span>
}
</div>
<button type="submit" [disabled]="!regForm.valid()">
Register
</button>
</form>
`
})
export class RegistrationComponent {
registration = signal({
email: '',
password: '',
age: 0
});
regForm = form(this.registration, {
validators: {
email: [validators.required, validators.email],
password: [validators.required, validators.minLength(8)],
age: [validators.required, validators.pattern(/^\d+$/)]
}
});
onSubmit() {
if (this.regForm.valid()) {
console.log('Valid form:', this.registration());
}
}
}
Custom Components Without ControlValueAccessor
Binding to custom components is signal based and easier than ever - no more need for ControlValueAccessor:
import { Component, signal, model } from '@angular/core';
import { Field } from '@angular/forms/signals';
@Component({
selector: 'app-star-rating',
template: `
<div class="stars">
@for (star of [1,2,3,4,5]; track star) {
<button
[class.filled]="star <= value()"
(click)="value.set(star)">
★
</button>
}
</div>
`
})
export class StarRatingComponent {
// model() automatically syncs with Signal Forms
value = model(0);
}
// Usage in parent
@Component({
selector: 'app-review',
imports: [Field, StarRatingComponent],
template: `
<app-star-rating [field]="reviewForm.rating" />
<textarea [field]="reviewForm.comment"></textarea>
`
})
export class ReviewComponent {
review = signal({ rating: 0, comment: '' });
reviewForm = form(this.review);
}
Angular Aria - Accessible Components
Angular v21 launches Angular Aria in Developer Preview - a new modern library for common UI patterns with accessibility as its number one priority.
Installation
npm i @angular/aria
Available Components
Angular Aria provides 8 UI patterns encompassing 13 completely unstyled components:
- Accordion - Collapsible content panels
- Combobox - Searchable dropdown
- Grid - Data grid with keyboard navigation
- Listbox - Selection list
- Menu - Multi-level menus
- Tabs - Tab navigation
- Toolbar - Action toolbar
- Tree - Hierarchical tree view
Usage Example
import { Component } from '@angular/core';
import {
Tabs,
TabList,
Tab,
TabPanel
} from '@angular/aria';
@Component({
selector: 'app-product-details',
imports: [Tabs, TabList, Tab, TabPanel],
template: `
<aria-tabs>
<aria-tab-list>
<aria-tab id="desc">Description</aria-tab>
<aria-tab id="specs">Specifications</aria-tab>
<aria-tab id="reviews">Reviews</aria-tab>
</aria-tab-list>
<aria-tab-panel for="desc">
<p>Product description content...</p>
</aria-tab-panel>
<aria-tab-panel for="specs">
<table>...</table>
</aria-tab-panel>
<aria-tab-panel for="reviews">
<app-reviews />
</aria-tab-panel>
</aria-tabs>
`,
styles: `
/* Style it your way! */
aria-tab {
padding: 0.75rem 1.5rem;
border: none;
background: transparent;
cursor: pointer;
}
aria-tab[aria-selected="true"] {
border-bottom: 2px solid var(--primary);
color: var(--primary);
}
aria-tab-panel {
padding: 1.5rem;
}
`
})
export class ProductDetailsComponent {}
Multi-Level Menu Example
import { Component } from '@angular/core';
import { Menu, MenuItem, MenuTrigger } from '@angular/aria';
@Component({
selector: 'app-context-menu',
imports: [Menu, MenuItem, MenuTrigger],
template: `
<button [ariaMenuTrigger]="mainMenu">
Actions ▼
</button>
<aria-menu #mainMenu>
<aria-menu-item (activate)="onNew()">New</aria-menu-item>
<aria-menu-item (activate)="onOpen()">Open</aria-menu-item>
<!-- Nested submenu -->
<aria-menu-item [ariaMenuTrigger]="exportMenu">
Export →
</aria-menu-item>
<aria-menu #exportMenu>
<aria-menu-item (activate)="exportAs('pdf')">PDF</aria-menu-item>
<aria-menu-item (activate)="exportAs('csv')">CSV</aria-menu-item>
<aria-menu-item (activate)="exportAs('json')">JSON</aria-menu-item>
</aria-menu>
</aria-menu>
`
})
export class ContextMenuComponent {
onNew() { /* ... */ }
onOpen() { /* ... */ }
exportAs(format: string) { /* ... */ }
}
Zoneless is Now Default
New Angular applications no longer include zone.js by default! This is a major architectural change in v21.
Why Zoneless?
- Over 1,400 Angular applications publicly using zoneless change detection
- Hundreds of zoneless apps running in production at Google
- More than half of new Angular apps at Google built zoneless since mid-2024
Benefits of Zoneless
- Better Core Web Vitals - Improved performance metrics
- Native async-await - No zone.js patching
- Reduced bundle size - ~15KB saved
- Easier debugging - No zone.js stack traces
- Better ecosystem compatibility - Works with any async library
New Project Setup (Automatic)
New Angular v21 projects are zoneless by default:
ng new my-app
# Zone.js is NOT included!
Zoneless Component Pattern
import {
Component,
signal,
computed,
ChangeDetectionStrategy
} from '@angular/core';
@Component({
selector: 'app-todo-list',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<input
[value]="newTodo()"
(input)="newTodo.set($event.target.value)"
(keyup.enter)="addTodo()">
<button (click)="addTodo()">Add</button>
<ul>
@for (todo of filteredTodos(); track todo.id) {
<li [class.done]="todo.done">
<input
type="checkbox"
[checked]="todo.done"
(change)="toggleTodo(todo.id)">
{{ todo.text }}
</li>
}
</ul>
<p>{{ remaining() }} items left</p>
`
})
export class TodoListComponent {
todos = signal<Todo[]>([]);
newTodo = signal('');
filter = signal<'all' | 'active' | 'done'>('all');
filteredTodos = computed(() => {
const f = this.filter();
if (f === 'all') return this.todos();
return this.todos().filter(t =>
f === 'done' ? t.done : !t.done
);
});
remaining = computed(() =>
this.todos().filter(t => !t.done).length
);
addTodo() {
const text = this.newTodo().trim();
if (!text) return;
this.todos.update(todos => [
...todos,
{ id: Date.now(), text, done: false }
]);
this.newTodo.set('');
}
toggleTodo(id: number) {
this.todos.update(todos =>
todos.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
}
}
Vitest as Default Test Runner
Vitest support is now stable and production ready in Angular v21! It’s the new default test runner for new projects.
Running Tests
ng test
# Uses Vitest by default!
Sample Output
✓ src/app/app.component.spec.ts (3 tests) 2.46s
✓ AppComponent should create the app
✓ AppComponent should have as title 'my-app'
✓ AppComponent should render title
Test Files 1 passed (1)
Tests 3 passed (3)
Duration 2.46s
Vitest Configuration
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import angular from '@angular/vitest/plugin';
export default defineConfig({
plugins: [angular()],
test: {
globals: true,
environment: 'jsdom',
include: ['src/**/*.spec.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
},
},
});
Writing Tests with Vitest
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/angular';
import { TodoListComponent } from './todo-list.component';
describe('TodoListComponent', () => {
it('should add a new todo', async () => {
await render(TodoListComponent);
const input = screen.getByRole('textbox');
const addButton = screen.getByRole('button', { name: /add/i });
await fireEvent.input(input, { target: { value: 'Buy groceries' } });
await fireEvent.click(addButton);
expect(screen.getByText('Buy groceries')).toBeInTheDocument();
});
it('should toggle todo completion', async () => {
await render(TodoListComponent, {
componentProperties: {
todos: signal([
{ id: 1, text: 'Test todo', done: false }
])
}
});
const checkbox = screen.getByRole('checkbox');
await fireEvent.click(checkbox);
expect(checkbox).toBeChecked();
});
it('should filter todos', async () => {
await render(TodoListComponent, {
componentProperties: {
todos: signal([
{ id: 1, text: 'Done task', done: true },
{ id: 2, text: 'Active task', done: false }
])
}
});
// Show only active
const filterSelect = screen.getByRole('combobox');
await fireEvent.change(filterSelect, { target: { value: 'active' } });
expect(screen.queryByText('Done task')).not.toBeInTheDocument();
expect(screen.getByText('Active task')).toBeInTheDocument();
});
});
Migrating from Jasmine
ng g @schematics/angular:refactor-jasmine-vitest
MCP Server for AI Agents
Angular v21 includes a stable MCP (Model Context Protocol) Server built into the Angular CLI, enabling AI agents to have full context for Angular development.
Available Tools
The Angular MCP server provides 7 stable and experimental tools:
| Tool | Description |
|---|---|
get_best_practices | Provides Angular best practices guide |
list_projects | Finds all Angular projects in workspace |
search_documentation | Queries official angular.dev documentation |
find_examples | Provides up-to-date code examples |
onpush_zoneless_migration | Analyzes and plans zoneless migration |
modernize | Performs code migrations using schematics |
ai_tutor | Interactive Angular learning assistant |
Configuring MCP Server
// .vscode/settings.json (for VS Code + Copilot)
{
"mcp.servers": {
"angular": {
"command": "npx",
"args": ["@angular/cli", "mcp"]
}
}
}
Example AI Interaction
User: Help me migrate this component to zoneless
AI (using onpush_zoneless_migration tool):
Based on analysis, here's the migration plan:
1. Change detection is already OnPush ✓
2. Replace setTimeout with signals
3. Convert Observable subscriptions to toSignal()
4. Remove zone.js from polyfills
Let me apply these changes...
Template Improvements
Regular Expressions in Templates
Angular v21 now supports regex in templates:
@Component({
template: `
@let isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email());
@let isValidPhone = /^\d{10,}$/.test(phone());
@if (!isValidEmail) {
<p class="error">Invalid email format</p>
}
@if (!isValidPhone) {
<p class="error">Phone must be at least 10 digits</p>
}
`
})
export class ValidationComponent {
email = signal('');
phone = signal('');
}
Customizable IntersectionObserver for @defer
@Component({
template: `
@defer (on viewport({trigger, rootMargin: '100px'})) {
<heavy-component />
}
@defer (on viewport({root: scrollContainer, threshold: 0.5})) {
<lazy-images />
}
`
})
export class LazyLoadComponent {
scrollContainer = viewChild<ElementRef>('container');
}
Built-in Signals Formatter
Enable Chrome DevTools custom formatters to see signal values directly:
// In DevTools console, signals show actual values:
// sig: "hello" (instead of Signal object)
// count: 42 (instead of WritableSignal object)
Generic SimpleChanges
@Component({...})
export class MyComponent implements OnChanges {
@Input() name!: string;
@Input() age!: number;
ngOnChanges(changes: SimpleChanges<MyComponent>) {
// Now fully typed!
if (changes.name) {
// changes.name.currentValue is string
console.log(`Name: ${changes.name.currentValue}`);
}
if (changes.age) {
// changes.age.currentValue is number
console.log(`Age: ${changes.age.currentValue}`);
}
}
}
Advanced Techniques
State Management with Signal Store
// stores/cart.store.ts
import { Injectable, signal, computed } from '@angular/core';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
@Injectable({ providedIn: 'root' })
export class CartStore {
private _items = signal<CartItem[]>([]);
private _loading = signal(false);
// Selectors (read-only)
items = this._items.asReadonly();
loading = this._loading.asReadonly();
totalItems = computed(() =>
this._items().reduce((sum, i) => sum + i.quantity, 0)
);
totalPrice = computed(() =>
this._items().reduce((sum, i) => sum + i.price * i.quantity, 0)
);
// Actions
addItem(product: Product) {
this._items.update(items => {
const existing = items.find(i => i.id === product.id);
if (existing) {
return items.map(i =>
i.id === product.id
? { ...i, quantity: i.quantity + 1 }
: i
);
}
return [...items, { ...product, quantity: 1 }];
});
}
removeItem(id: string) {
this._items.update(items => items.filter(i => i.id !== id));
}
async checkout() {
this._loading.set(true);
try {
const response = await fetch('/api/checkout', {
method: 'POST',
body: JSON.stringify({ items: this._items() })
});
if (response.ok) {
this._items.set([]);
}
} finally {
this._loading.set(false);
}
}
}
Resource API Pattern
import { Component, resource, ResourceStatus } from '@angular/core';
@Component({
selector: 'app-user-profile',
template: `
@switch (userResource.status()) {
@case (ResourceStatus.Loading) {
<div class="skeleton">Loading...</div>
}
@case (ResourceStatus.Error) {
<div class="error">
Failed to load: {{ userResource.error()?.message }}
<button (click)="userResource.reload()">Retry</button>
</div>
}
@case (ResourceStatus.Success) {
<div class="profile">
<img [src]="userResource.value()?.avatar" alt="Avatar">
<h2>{{ userResource.value()?.name }}</h2>
<p>{{ userResource.value()?.email }}</p>
</div>
}
}
`
})
export class UserProfileComponent {
ResourceStatus = ResourceStatus;
userId = input.required<string>();
userResource = resource({
request: () => this.userId(),
loader: async ({ request: userId }) => {
const res = await fetch(`/api/users/${userId}`);
if (!res.ok) throw new Error('Failed to load user');
return res.json();
}
});
}
Combining Signal Forms with Stores
@Component({
selector: 'app-checkout',
imports: [Field],
template: `
<div class="cart-summary">
<p>Items: {{ cart.totalItems() }}</p>
<p>Total: {{ cart.totalPrice() | currency }}</p>
</div>
<form (submit)="onSubmit()">
<input [field]="checkoutForm.email" placeholder="Email">
<input [field]="checkoutForm.address" placeholder="Address">
<input [field]="checkoutForm.cardNumber" placeholder="Card Number">
<button
type="submit"
[disabled]="!checkoutForm.valid() || cart.loading()">
{{ cart.loading() ? 'Processing...' : 'Pay Now' }}
</button>
</form>
`
})
export class CheckoutComponent {
cart = inject(CartStore);
checkout = signal({
email: '',
address: '',
cardNumber: ''
});
checkoutForm = form(this.checkout, {
validators: {
email: [validators.required, validators.email],
address: [validators.required, validators.minLength(10)],
cardNumber: [validators.required, validators.pattern(/^\d{16}$/)]
}
});
async onSubmit() {
if (this.checkoutForm.valid()) {
await this.cart.checkout();
}
}
}
Migration Guide
Updating to Angular v21
# Update Angular CLI and Core
ng update @angular/cli@21 @angular/core@21
# Update other Angular packages
ng update @angular/material@21
Requirements
- Node.js: v20.x or higher
- TypeScript: v5.8 or higher
Key Changes
- Zoneless by Default: New apps don’t include zone.js
- Vitest Default: New test runner for new projects
- Signal Forms: New experimental forms API
- CLDR Updated: v41 → v47 for better i18n
Migrating Existing Apps to Zoneless
# Use the MCP tool for guided migration
ng mcp onpush_zoneless_migration
# Or manually:
# 1. Add to app.config.ts
import { provideZonelessChangeDetection } from '@angular/core';
export const appConfig = {
providers: [
provideZonelessChangeDetection()
]
};
# 2. Remove zone.js from angular.json polyfills
# 3. Ensure all components use OnPush + Signals
Migrating to Vitest
ng g @schematics/angular:refactor-jasmine-vitest
Conclusion
Angular v21 is a landmark release that embraces AI-powered development while maintaining the stability and robustness Angular is known for. With Signal Forms providing a reactive forms experience, Angular Aria delivering accessible components, and zoneless becoming the default, Angular continues to evolve for modern web development.
The integration of MCP Server shows Angular’s commitment to AI-assisted development, allowing your favorite AI tools to understand and work with Angular code from day one.