Skip to main content

On This Page

Angular v21: Zoneless by Default and the Death of Zone.js

11 min read
Share

TL;DR

Angular v21 removes Zone.js from new projects by default, replacing it with a Signal-based reactivity system that eliminates the framework’s biggest performance bottleneck. Signal Forms (experimental) unifies data models and form state, killing the boilerplate of Reactive Forms. Incremental hydration solves the SSR “uncanny valley” where pages look interactive but aren’t. Vitest replaces Karma as the default test runner, cutting test execution time by 60-80%. The framework also ships an MCP server that lets AI coding assistants understand your project structure, and a headless @angular/aria package for accessible UI components. For teams running Angular at scale, v21 is the most significant release since the 2016 rewrite.


Zoneless: The 100KB Bundle You No longer Need

Zone.js has been Angular’s dirty secret since 2016. It monkey-patches every async API in the browser, setTimeout, Promise.then, XMLHttpRequest, DOM events, to intercept callbacks and trigger change detection. This “magic” came at a brutal cost:

Performance: A single scroll event could trigger dirty-checking across thousands of components, even if nothing actually changed. In heavy dashboards with real-time data feeds, Zone-based apps would drop from 60 FPS to 45 FPS under load.

Bundle size: 100KB uncompressed. For performance-sensitive apps, that’s a non-trivial tax.

Debugging hell: Stack traces polluted with Zone internals. Good luck finding where your error actually originated.

Global state mutation: Patching global browser APIs is an anti-pattern that can conflict with other libraries.

Angular v21 makes new projects zoneless by default. No more Zone.js. No more implicit change detection. The framework now relies on Signals, explicit, fine-grained reactive primitives that notify Angular exactly which components changed.

How Signal-Based Rendering Actually Works

When a component template reads a Signal, Angular registers that view as a dependency:

@Component({
  template: \`<div>Count: {{ count() }}</div>\`
})
class CounterComponent {
  count = signal(0);
  
  increment() {
    this.count.set(this.count() + 1);  // Notifies Angular
  }
}

When count.set() is called, Angular knows precisely which DOM nodes depend on that Signal. It schedules a render pass only for those nodes, no top-down tree traversal, no wasted cycles checking components that didn’t change.

This shifts from O(n) complexity (where n = total components) to O(m) (where m = components that actually changed). For enterprise dashboards with 500+ components, this is the difference between a responsive UI and a laggy one.

Migrating Existing Apps

For existing apps, enable zoneless via provideZonelessChangeDetection():

// app.config.ts
import { provideZonelessChangeDetection } from '@angular/core';

export const appConfig: ApplicationConfig = {
  providers: [
    provideZonelessChangeDetection(),
    provideRouter(routes)
  ]
};

Then delete zone.js from angular.json polyfills and remove the import from polyfills.ts.

The catch: Code relying on implicit change detection (e.g., setTimeout(() => this.data = newData)) won’t trigger updates anymore. You have three options:

  1. Use Signals (preferred): Wrap state in signal() and update via .set() or .update()
  2. Keep using RxJS + AsyncPipe: The pipe calls markForCheck() internally
  3. Manual marking: Call ChangeDetectorRef.markForCheck() explicitly

Event listeners ((click)) and @Input() changes still trigger detection automatically.


Signal Forms: Model-First, Validation-Second

Reactive Forms have been Angular’s standard for complex form logic since day one. But they suffer from architectural baggage:

Synchronization drift: You maintain two separate objects, the form model (FormGroup) and your domain model. Keeping them in sync is manual and error-prone.

Type safety theater: Even with strict typing, you’re calling imperative methods like .setValue() and .patchValue().

RxJS friction: To react to changes, you subscribe to valueChanges (an Observable), then convert it to a Signal with toSignal(). This creates unnecessary adapters.

Angular v21 introduces Signal Forms (experimental), which inverts the relationship: your data model is the source of truth, and the form is just a projection of it.

The form() Primitive

Instead of creating a FormGroup and stuffing data into it, you define your model as a Signal and derive the form from it:

import { signal } from '@angular/core';
import { form, Field } from '@angular/forms/signals';

@Component({
  template: \`
    <form>
      <input type="text" [field]="regForm.username">
      @if (regForm.username().touched() && regForm.username().invalid()) {
        <span class="error">{{ regForm.username().errors()?.required?.message }}</span>
      }
      <button [disabled]="regForm.invalid()">Register</button>
    </form>
  \`
})
export class RegistrationComponent {
  // 1. Your domain model
  userModel = signal({ username: '', email: '' });
  
  // 2. The form is derived from the model
  regForm = form(this.userModel);
}

When the user types in the input, userModel updates automatically. When userModel updates programmatically, the form reflects it. No .patchValue() calls. No manual synchronization.

Schema-Based Validation

Validation decouples from the form control and moves to reusable schemas:

import { schema, required, minLength, email } from '@angular/forms/signals';

const userSchema = schema((node) => {
  required(node.username, { message: 'Username required' });
  minLength(node.username, 5, { message: 'Too short' });
  email(node.email, { message: 'Invalid email' });
});

const myForm = form(this.model, userSchema);

This aligns Angular with libraries like Zod or Yup. You can define schemas in shared domain logic and apply them to both frontend and backend (if both use TypeScript).

Dynamic Forms (Arrays) Are Actually Easy Now

Handling FormArray in Reactive Forms required verbose index management. Signal Forms treat arrays as just another value:

tasks = signal<Task[]>([]);
taskForm = form(this.tasks);

addTask() {
  this.tasks.update(current => [...current, { title: '', done: false }]);
  // Form automatically expands
}

removeTask(index: number) {
  this.tasks.update(current => current.filter((_, i) => i !== index));
  // Form automatically shrinks
}

No manual control pushing/popping. The form structure follows the data structure.


Incremental Hydration: Fixing the SSR Uncanny Valley

Traditional SSR suffers from a critical UX flaw: the page loads instantly (HTML from the server), but during hydration, while the browser downloads and executes JavaScript, it enters the “uncanny valley”. The page looks interactive, but clicking buttons does nothing because event listeners haven’t attached yet.

This kills your Interaction to Next Paint (INP) metric. Users rage-click, bounce, complain.

Angular v21 ships incremental hydration, which hydrates the app in chunks based on priority, not all at once.

Hydration Triggers

You wrap components in @defer blocks and specify when to hydrate:

@defer (hydrate on interaction) {
  <heavy-chart [data]="salesData" />
} @placeholder {
  <div class="chart-preview">
    <img src="static-preview.png" alt="Preview">
    <button>Load Interactive Chart</button>
  </div>
}

Available triggers:

TriggerWhen it hydratesUse case
`on idle`Browser main thread is freeFooter, secondary content
`on viewport`Element enters visible areaLong lists, galleries
`on interaction`User clicks/focusesHeavy widgets, charts
`on hover`Mouse moves over areaDropdowns, tooltips
`on immediate`After critical renderingCore navigation

Event Replay

To fully close the uncanny valley, v21 enables Event Replay by default. If a user clicks a button before hydration completes, Angular captures that event and replays it once the JavaScript loads. User intent is never lost.

This uses a tiny inlined script (derived from Google Search’s infrastructure) to queue events. The overhead is ~2KB.

Per-Route Rendering Modes

You can now mix Static Site Generation (SSG), Server-Side Rendering (SSR), and Client-Side Rendering (CSR) in a single app via ServerRoute config:

export const serverRouteConfig: ServerRoute[] = [
  { path: '/blog/**', renderMode: 'prerender' },      // SSG
  { path: '/dashboard', renderMode: 'server' },       // SSR
  { path: '/admin', renderMode: 'client' }            // CSR
];

This gives you granular control over performance vs. interactivity trade-offs.


Vitest: Karma is Finally Dead

Angular has shipped with Karma as the test runner since 2012. Karma launches an actual browser to run tests, which made sense when browser APIs were unpredictable. In 2025, it’s just slow.

Angular v21 adopts Vitest as the default test runner. Vitest runs in Node.js using jsdom to simulate the DOM, avoiding the overhead of launching Chrome. Test execution is 60-80% faster.

Migration Path

For existing projects, run:

ng g @schematics/angular:refactor-jasmine-vitest

This automates syntax changes (e.g., spyOnvi.spyOn). But be aware:

  • vi.fn() behaves differently than Jasmine spies for deeply nested object mocking
  • Some Jasmine-specific matchers don’t have direct Vitest equivalents

Configuration is straightforward:

// vitest.config.ts
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';

export default defineConfig({
  plugins: [angular()],
  test: {
    globals: true,
    environment: 'jsdom',
    include: ['src/**/*.{test,spec}.ts']
  }
});

HMR That Actually Works

Hot Module Replacement in Angular has always been flaky. Changing a template would often trigger a full page reload, losing form state.

v21 ships HMR 2.0 by default for styles and templates. The new implementation patches template definitions in the running app without destroying component instances. This preserves Signal state during development, tightening the feedback loop.


AI Integration: Your CLI as an MCP Server

Angular v21 supports the Model Context Protocol (MCP), a standard that lets AI coding assistants (Cursor, Claude, JetBrains AI) query your CLI directly.

When connected, the AI gains “X-ray vision” into your project:

  • Dependency graph: Which components depend on this service?
  • Route structure: How is navigation configured?
  • Build settings: What’s in angular.json?

This drastically reduces AI hallucinations. Instead of suggesting deprecated patterns (like NgModules in a standalone app), the AI can verify your actual project constraints.

Setup

Add this to your AI tool’s config (e.g., Claude Desktop):

{
  "mcpServers": {
    "angular-cli": {
      "command": "npx",
      "args": ["-y", "@angular/cli", "mcp"]
    }
  }
}

The AI can now execute ng generate commands, scaffold components, and understand your architecture without guessing.


@angular/aria: Headless Accessible UI

For years, Angular developers faced a choice: use Angular Material (accessible but hard to customize) or build from scratch (flexible but often inaccessible).

Angular v21 introduces @angular/aria (Developer Preview), a headless UI library that handles accessibility logic without rendering any DOM or styles.

It provides:

  • Combobox/Autocomplete: Typing, filtering, arrow-key selection
  • Listbox/Select: Single and multi-select with keyboard support
  • Menu/Menubar: Nested submenus, focus management
  • Accordion & Tabs: ARIA expansion states
  • Grid: 2D keyboard navigation for data tables

This lets you ship branded, bespoke UI components that are WCAG-compliant out of the box. For enterprise teams building Design Systems, this eliminates the need to maintain complex a11y logic.


Template Syntax Improvements

Small but useful quality-of-life upgrades:

Exponentiation operator:

{{ value ** 2 }}  <!-- No more Math.pow() -->

`in` operator for property checks:

{{ 'id' in user }}  <!-- Cleaner than user?.id !== undefined -->

Untagged template literals:

<div [class]="col-\${width}">  <!-- Dynamic string composition -->

httpResource: Declarative Data Fetching

The new httpResource API (experimental) simplifies reactive HTTP calls:

userId = signal(123);
userResource = httpResource(() => \`/api/users/\${this.userId()}\`);

// In template:
// {{ userResource.value() }}
// {{ userResource.isLoading() }}

When userId changes, the resource cancels pending requests and issues a new one automatically. This eliminates the boilerplate of switchMap and manual subscription management.


Migration Strategy for Production Apps

Phase 1: Enable zoneless

  • Add provideZonelessChangeDetection() to app.config.ts
  • Remove Zone.js from angular.json and polyfills
  • Test thoroughly, watch for setTimeout/Promise code that doesn’t update the UI

Phase 2: Audit change detection

  • Search for ChangeDetectorRef.detectChanges() calls
  • Replace with Signal updates or markForCheck()
  • Convert critical RxJS streams to Signals where it makes sense

Phase 3: Migrate to Vitest

  • Run ng g @schematics/angular:refactor-jasmine-vitest
  • Fix any test failures (check spy behavior differences)
  • Measure test execution time, should drop 60-80%

Phase 4: Experiment with Signal Forms

  • Start with new features, not mission-critical forms
  • Test schema-based validation patterns
  • Evaluate DX improvement before rolling out widely

Phase 5: Incremental hydration

  • Identify heavy components (charts, tables, widgets)
  • Wrap in @defer (hydrate on interaction/viewport)
  • Monitor INP metrics, should improve by 30-50%

Final Thoughts

Angular v21 isn’t just an incremental update, it’s the completion of a multi-year architectural overhaul. The framework has shed its legacy baggage (Zone.js, NgModules, Karma) and replaced it with modern, performant alternatives (Signals, standalone components, Vitest).

The introduction of Signal Forms and incremental hydration shows Angular isn’t just catching up, it’s pushing boundaries. The MCP integration and @angular/aria package solidify its position as the enterprise framework of choice.

For teams running Angular at scale, v21 is the green light to modernize. The migration path is well-documented, the performance wins are measurable, and the DX improvements are immediate.

Adoption Checklist:

  • New projects: Use Angular v21 defaults (zoneless, Vitest)
  • Existing apps: Migrate to zoneless incrementally, starting with low-risk modules
  • Forms: Experiment with Signal Forms in new features
  • SSR apps: Enable incremental hydration for heavy components
  • Testing: Migrate to Vitest, measure the speed improvement
  • AI workflows: Set up MCP if using AI coding assistants
  • Accessibility: Evaluate @angular/aria for Design System components

The renaissance is complete. Angular is fast, modern, and ready for the next decade of web development.

Continue reading

Next article

Natural Storytelling with Piper TTS

Related Content