Angular 21 isn’t just another release with niche APIs. It shifts the “default way” Angular apps are built and tested:
- Signal Forms (experimental): a new, signal-based form API that feels lightweight like template-driven forms but stays powerful like reactive forms.
- Zoneless by default: change detection becomes explicit and predictable—driven by signals, inputs, and events, not by Zone.js patching our browser APIs.
- Vitest as the modern test environment: Karma’s successor, with fast Node runs and optional real browser mode.
- ARIA directives: a first-class way to build accessible UI components more consistently.
- Enhanced MCP server (AI-assisted dev): better tooling integration for AI workflows.
0) Quick mental model (so everything clicks)
Angular 21 pushes Angular further into a reactive-by-design framework:
- State is reactive (signals/observables).
- Rendering is explicit (zoneless).
- Testing is modern (vitest + browser mode).
- Accessibility is built-in (ARIA directives).
If you understand one sentence, make it this:
In Angular 21, the UI updates because your state is reactive — not because Angular “noticed some async happened somewhere”.
1) Signal Forms (experimental): Reactive Forms powered by Signals
What problem does it solve?
Historically, Angular had two main form styles:
Before: Template-driven forms
- Very easy to start
- But harder to build complex/nested forms and validations
- Validation and logic often end up scattered
Before: Reactive forms (FormControl, FormGroup, FormArray)
- Very powerful and scalable
- But also verbose (lots of boilerplate)
Now: Signal Forms
Signal Forms aim to combine:
- Template-driven simplicity (bind fields directly)
- Reactive-form power (validation, groups/arrays, composability)
- Signal-based performance model (no zone dependency)
1.1 A “Before vs Now” example
Let’s use a flight search form: from, to, nested details, and a list of layovers.
Before (Reactive Forms, simplified) ==>
import { Component } from '@angular/core';
import { FormArray, FormBuilder, Validators } from '@angular/forms';
@Component({
selector: 'app-flight-search',
templateUrl: './flight-search.component.html',
})
export class FlightSearchComponent {
form = this.fb.group({
from: this.fb.control('Graz', [Validators.required, Validators.minLength(3)]),
to: this.fb.control('Hamburg', [Validators.required, Validators.minLength(3)]),
details: this.fb.group({
maxLayovers: this.fb.control(0),
maxPrice: this.fb.control(200),
}),
layovers: this.fb.array([
this.fb.group({
airport: this.fb.control(''),
minDuration: this.fb.control(0),
}),
]),
});
constructor(private fb: FormBuilder) {}
get layovers() {
return this.form.get('layovers') as FormArray;
}
addLayover() {
this.layovers.push(
this.fb.group({
airport: this.fb.control(''),
minDuration: this.fb.control(0),
})
);
}
search() {
const { from, to } = this.form.value;
// ...
}
}
You can see the power—but also the verbosity (builders, getters, arrays, groups).
Now (Signal Forms, from your source example) ==>
import { Component, signal } from '@angular/core';
import { form, required, minLength, validate, SchemaPath } from '@angular/forms/signals';
type Layover = { airport: string; minDuration: number };
@Component({
selector: 'app-flight-search',
templateUrl: './flight-search.component.html',
})
export class FlightSearchComponent {
filter = signal({
from: 'Graz',
to: 'Hamburg',
details: {
maxLayovers: 0,
maxPrice: 200,
},
layovers: [{ airport: '', minDuration: 0 }] as Layover[],
});
filterForm = form(this.filter, (path) => {
required(path.from);
minLength(path.from, 3);
required(path.to);
minLength(path.to, 3);
const allowed = ['Graz', 'Hamburg', 'Paris'];
validateAirport(path.from, allowed);
});
addLayover(): void {
this.filter.update((filter) => ({
...filter,
layovers: [...filter.layovers, { airport: '', minDuration: 0 }],
}));
}
search(): void {
const { from, to } = this.filterForm().value();
// ...
}
}
function validateAirport(path: SchemaPath<string>, allowed: string[]) {
validate(path, (ctx) => {
if (allowed.includes(ctx.value())) return null;
return {
kind: 'airport_not_supported',
allowed,
actual: ctx.value(),
};
});
}
1.2 What is a FieldTree (in plain English)?
Signal Forms generate a FieldTree. Think of it as:
- A signal-based object that tracks everything about a field:
- current value
- validation errors
- dirty/touched state
- And it mirrors your data structure:
- object ? nested fields
- array ? repeating groups
- primitive ? single input
So Angular can update only the parts of the form that changed.
1.3 Binding inputs (Signal Forms template)
Signal Forms bind fields through a field directive:
<form>
<label>
From
<input aria-label="from" [field]="filterForm.from" />
</label>
<pre>{{ filterForm.from().errors() | json }}</pre>
<label>
To
<input aria-label="to" [field]="filterForm.to" />
</label>
<pre>{{ filterForm.to().errors() | json }}</pre>
<!-- "Field Group" -->
<input type="number" [field]="filterForm.details.maxLayovers" />
<input type="number" [field]="filterForm.details.maxPrice" />
<!-- "Field Array" -->
@for (layover of filterForm.layovers; track $index) {
<input [field]="layover.airport" />
<input type="number" [field]="layover.minDuration" />
}
</form>
Key takeaway
- Our data lives in a signal (
filter) - Our “form state” lives in the generated FieldTree (
filterForm) - We bind
FieldTree nodesdirectly to inputs
2) Zoneless by Default: Change detection without Zone.js
Before: How Angular “knew” to update the UI
Angular relied on Zone.js to monkey-patch browser APIs (timers, promises, events). After an async task finished, Zone would tell Angular:
“Something happened—run change detection.”
This was convenient, but had downsides:
- Patching can complicate debugging/stack traces
- It can trigger change detection more often than needed
- It adds bundle weight (often not huge, but not zero)
Now: Zoneless is the default in Angular 21
Angular now expects our UI updates to be driven by:
- Signals (recommended)
- Observables bound via
asyncpipe (still great) - Template events like
(click)(still triggers updates) - Inputs (parent-to-child updates)
This matches the “explicit reactivity” model.
2.1 Opting back into Zone.js (if you must)
If we want the old behavior, we can re-enable Zone-based change detection:
import { bootstrapApplication, provideZoneChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [provideZoneChangeDetection()],
});
And we must also re-add zone.js to polyfills (angular.json / polyfills.ts) depending on our setup.
2.2 A newcomer-friendly “what breaks” checklist
Zoneless usually works wherever OnPush used to work well. Still, watch for:
- Mutable state that changes “in place” (Angular can’t tell something changed)
- Prefer immutable updates (new object/array references)
- Third-party libraries that assumed Zone.js exists
- Update them or replace them if needed
- “Hacky” patterns like
setTimeout(() => ...)used to force UI refresh- Replace with signals,
asyncpipe, or explicit state updates
- Replace with signals,
Migration guide
1. Remove Zone.js
Delete zone.js (and zone.js/testing) from angular.json polyfills (build & test). If you have a polyfills.ts, remove import ‘zone.js’.
2. Enable zoneless change detection
import { bootstrapApplication, provideZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection()]
});
3. (Recommended) Add browser error listeners
import { provideBrowserGlobalErrorListeners } from '@angular/core';
bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
provideBrowserGlobalErrorListeners()
]
});
4. Run tests & remove warning If you see NG0914: using zoneless but still loading Zone.js, you missed a polyfill. Clean it up and rerun.
“Will my app still update when I click a button?”
Yes. Template events (like (click)) still trigger change detection. Signals and input changes do, too. What we lose is the blanket “everything async triggers CD” behavior. If we relied on that implicitly, now we’ll make it explicit—which is a good thing for performance and clarity.
Common patterns that “just work”
- Signals drive UI:
// model signal
const count = signal(0);
const double = computed(() => count() * 2);
// event updates the signal
increment() { count.set(count() + 1); }
- HTTP + Signals: Assign the result to a signal (or resource) and bind to it in the template; the UI updates when the signal changes.
- markForCheck: Calling markForCheck will basically trigger application rendering, so sometimes, instead of refactoring to signals, a markForCheck will just work too!
Gotchas to check for
- Third-party code assuming Zone.js is present. Most major libs are zoneless-ready, but if something patched timers or used Zone APIs directly, we may need an update or a small workaround. (Good news: CDK & Material already have zoneless support.)
- Legacy “async triggers UI” assumptions. If you previously depended on a random setTimeout to “poke” CD, switch to signals or dispatch a proper event.
3) Vitest replaces Karma: faster, modern, and zoneless
Angular has been moving away from Karma for a while. Angular 21 makes Vitest the modern direction, using:
@angular/build:unit-test(dedicated builder)- Zoneless tests by default
3.1 Migration: schematic
For existing projects:
ng g @schematics/angular:refactor-jasmine-vitest
3.2 “Before vs Now”: spies and mocks
Before (Jasmine)
spyOn(flightService, 'find').and.callThrough();
spyOn(flightService, 'find').and.returnValue(of([]));
Now (Vitest)
import { vi } from 'vitest';
vi.spyOn(flightService, 'find'); // calls through by default
vi.spyOn(flightService, 'find').mockImplementation((_from, _to) => of([]));
3.3 Goodbye fakeAsync/tick (zoneless tests) ? hello Vitest fake timers
Because Vitest tests run zoneless, Angular’s Zone testing utilities like fakeAsync and tick() are not available.
If we need “time control” (debounce, timers, etc.), we should use Vitest fake timers:
import { TestBed } from '@angular/core/testing';
import { debounceTime, Subject } from 'rxjs';
import { vi } from 'vitest';
import { toSignal } from '@angular/core/rxjs-interop';
describe('simulated input', () => {
beforeEach(() => {
vi.useFakeTimers();
});
it('is updated after debouncing', async () => {
await TestBed.runInInjectionContext(async () => {
const input = createInput();
input.set('Hello');
await vi.runAllTimersAsync();
expect(input.value()).toBe('Hello');
});
});
});
function createInput() {
const input$ = new Subject<string>();
const inputSignal = toSignal(input$.pipe(debounceTime(300)), {
initialValue: '',
});
return {
value: inputSignal,
set(value: string) {
input$.next(value);
},
};
}
Why runAllTimersAsync()?
Because timers often trigger microtasks (Promises). The async variant waits for those too, making tests more deterministic.
4) Vitest Browser Mode: real browser tests (Chromium/Firefox/WebKit)
By default, Vitest runs tests in Node with a simulated DOM (via happy-dom or jsdom). That’s fast—but not always realistic.
If we want real browser behavior, we can use browser mode.
4.1 Install a browser provider (Playwright-based)
npm install -D @vitest/browser-playwright
4.2 Configure browsers in angular.json
{
"test": {
"builder": "@angular/build:unit-test",
"options": {
"browsers": ["Chromium"]
},
"configurations": {
"ci": {
"browsers": ["ChromiumHeadless"]
}
}
}
}
we can also run multiple browsers (e.g., Chromium, Firefox, WebKit) depending on our needs.
4.3 Example test using accessibility queries
import { page } from 'vitest/browser';
it('should have a disabled search button without params', async () => {
await page.getByLabelText('from').fill('');
await page.getByLabelText('to').fill('');
const button = page.getByRole('button', { name: 'search' }).element() as HTMLButtonElement;
expect(button.disabled).toBeTruthy();
});
Why this is great for newcomers
This is the same “testing philosophy” popularized by Testing Library:
- Query by labels and roles, not by fragile CSS selectors.
- Tests become closer to how users (and screen readers) interact with the UI.
5) Enhanced MCP server: AI-supported development workflows
Angular 21 also mentions an enhanced MCP server—which is about connecting AI tools to our dev environment in a structured way.
What it means in practice
Instead of “copy/paste prompts”, MCP-style integration can allow tools to:
- understand our workspace context (files, structure)
- run guided refactors more safely
- generate changes with better consistency
What to watch for
- Treat it as an assistant, not an authority
- Keep code review standards high
- Prefer small, reviewable diffs
