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.

D
Dery Febriantara Developer
Angular v21: Complete Guide to New Features and Advanced Techniques

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

  1. Signal Forms (Experimental)
  2. Angular Aria - Accessible Components
  3. Zoneless is Now Default
  4. Vitest as Default Test Runner
  5. MCP Server for AI Agents
  6. Template Improvements
  7. Advanced Techniques
  8. 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

  1. Better Core Web Vitals - Improved performance metrics
  2. Native async-await - No zone.js patching
  3. Reduced bundle size - ~15KB saved
  4. Easier debugging - No zone.js stack traces
  5. 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:

ToolDescription
get_best_practicesProvides Angular best practices guide
list_projectsFinds all Angular projects in workspace
search_documentationQueries official angular.dev documentation
find_examplesProvides up-to-date code examples
onpush_zoneless_migrationAnalyzes and plans zoneless migration
modernizePerforms code migrations using schematics
ai_tutorInteractive 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

  1. Zoneless by Default: New apps don’t include zone.js
  2. Vitest Default: New test runner for new projects
  3. Signal Forms: New experimental forms API
  4. 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.


Sources