What's new in Angular 21.2?

Angular 21.2.0 is here!

Angular logo

Angular 21.2 is the second minor release of the v21 cycle. Let's dive in!

Templates

Arrow functions

One of the most requested template syntax improvements is finally here: we can now use arrow functions directly in templates.

<button (click)="count.update(n => n + 1)">+1</button>

Note that you can't use an arrow function in an event listener to call a method. (click)="() => doSomething()" will not work, and will throw the following error at compile time:

Arrow function will not be invoked in this event listener. Did you intend to call a method?

instanceof is now also supported in templates, but I don't think we ever missed this one 🤷.

Exhaustive @switch checks

Angular now supports exhaustive type-checking for control-flow @switch blocks. You can opt in by ending the switch with @default never;.

@switch (status()) {
  @case ('idle') { 
    <p>Idle</p> 
  }
  @case ('loading') { 
    <p>Loading</p>
  }
  @default never;
}

If status() can also be another value (for example 'error'), the template type-checker now reports it at compile time:

[ERROR] TS2322: Type '"error"' is not assignable to type 'never'. [plugin angular-compiler]

    src/app/live/live.html:7:13:
      7     @switch (status()) {

I'm super happy to see this landing, as I opened the original feature request when the control flow syntax was announced, back in 2023 😍.

ChangeDetectionStrategy.Eager

No, this is not a new change detection strategy, but an alias for ChangeDetectionStrategy.Default. This is part of the ongoing effort to change the default change detection strategy to OnPush, as you can read in the official RFC.

In the next major release, the default change detection strategy will be switched to OnPush, and the Eager alias will be the only way to explicitly opt in to the old default behavior (Default is marked as deprecated in v21.1). That means components with no specified change detection strategy will be using OnPush and we'll have to explicitly set ChangeDetectionStrategy.Eager for components that are not OnPush compatible. An automatic migration will take care of that for existing components.

Resource snapshot

Angular now exposes a resource state as a first-class value with ResourceSnapshot<T>. A resource has a new snapshot() signal, which is a simple object containing a status and either a value or an error. Angular also introduces resourceFromSnapshots(...) to turn a snapshot signal back into a Resource.

This is an advanced API that allows you to customize the behavior of resources. You can use normal signal composition (computed, linkedSignal) to transform resource states, then convert the result back to a resource. This pattern makes it much easier to build reusable resource utilities. For example, built-in resources set the value to undefined when loading. If you don't like this behavior, you can implement a resource that keeps the stale value while loading.

function withPreviousValue<T>(input: Resource<T>): Resource<T> {
  const derived = linkedSignal<ResourceSnapshot<T>, ResourceSnapshot<T>>({
    source: () => input.snapshot(),
    computation: (snap, previous) => {
      if (snap.status === 'loading' && previous?.value?.status === 'resolved') {
        // keep previous value while loading next one
        return { status: 'loading', value: previous.value.value };
      }
      return snap;
    }
  });

  return resourceFromSnapshots(derived);
}

Signal Forms

Signal Forms received several improvements in Angular v21.2.

FormRoot directive and submission option

One of the interesting Signal Forms additions in v21.2 is the FormRoot directive. Instead of manually handling (submit) and calling submit(...), you can now bind the form directly in the template:

<form [formRoot]="loginForm">
  <input [formField]="loginForm.login" />
  <button type="submit">Log in</button>
</form>

Submission logic can be declared in the form() configuration directly:

protected readonly loginForm = form(this.credentials, {
  submission: {
    action: async () => await this.userService.authenticate(this.credentials().login, this.credentials().password),
    onInvalid: () => this.invalidSubmission.set(true),
    ignoreValidators: 'none'
  }
});

As you can see, this submission option also allows to define an onInvalid callback, which is called when the form is submitted while invalid. The ignoreValidators option let you define whether the validation should be ignored when submitting the form or not:

  • pending (default value) ignore pending async validators, but not synchronous validators;
  • all ignore all validators;
  • none don't ignore any validator.

The FormRoot directive also automatically adds the novalidate attribute to the form element, preventing native browser validation from interfering with Signal Forms validation.

Note that the existing submit() function now uses these form-level defaults. So if action is defined in submission, you no longer need to pass it if you manually call submit(). Angular v21.2 in fact updated the submit() API to let you pass options as the second parameter (action, onInvalid, ignoreValidators) to override these defaults, and the submit function now returns a Promise<boolean>.

focus and registerAsBinding

As explained in our previous blog post, Angular v21.1 introduced the focusBoundControl method on a field to programmatically focus its form control in the DOM:

protected readonly loginForm = form(this.credentials, {
  submission: {
    action: // ...
    onInvalid: () => {
      // 👇 automatically focus the first field with an error
      const firstError = this.loginForm().errorSummary()[0];
      if (firstError?.fieldTree) {
        firstError.fieldTree().focusBoundControl();
      }
    }

A typical FormField will focus its host element (input, select, etc.) when focusBoundControl is called. For custom form controls that implements FormValueControl or FormCheckControl, you'll need to implement the focus method to define how the control should be focused when focusBoundControl is called:

@Component({
  // ...
})
export class BirthYearInput implements FormValueControl<number | null> {
  // ...
  // 👇
  focus() {
    this.birthYearInput().nativeElement.focus();
  }

But we can also have components or directives that don't implement FormValueControl or FormCheckControl, and instead receive the field as an input and bind it in the template. In this case, we can use the new registerAsBinding method to register the field as a binding, which will make focusBoundControl focus the chosen element:

@Component({
  // ...
  template: `
    <input type="password" [formField]="formField()" />
    <button #button (click)="generate()">Generate</button>
   `
})
export class Password {
  readonly formField = input.required<FieldTree<string>>();
  readonly button = viewChild.required<ElementRef<HTMLButtonElement>>('button');

  constructor() {
    // 👇 register the field as a binding, and specify to focus the button element
    inject(FormField).registerAsBinding({
      focus: () => this.button().nativeElement.focus()
    });
  }
}

If the custom control is a directive, the registerAsBinding call can be done without specifying the focus method, as the directive host element will be focused by default:

@Directive({
  // ...
})
export class PasswordDirective {
  readonly formField = input.required<FieldTree<string>>();
  
  constructor() {
    // 👇 the directive host element will be focused by default
    inject(FormField).registerAsBinding();
  }
}

Note: the focus API now accepts and propagates FocusOptions which allows control over focus behavior (for example, preventing scroll on focus with { preventScroll: true }).

transformedValue for custom controls

Another nice addition for custom controls is the new transformedValue utility. It helps synchronize a raw value entered by the user with the model value using parse and format functions, while automatically reporting parse errors to the parent form. The parse function can either return an object with a value property containing the parsed value, or an object with an errors property containing an array of errors if the value is invalid. In this example, the user types a duration like 20m or 1h, and the form model stores the duration in minutes. If the value is invalid, then a custom parse error is reported to the form, which can be used to display an error message in the UI. You can also return built-in validation errors from the parse function, for example to report that a negative duration is not allowed using a min error.

@Component({
  selector: 'duration-input',
  template: `<input [value]="rawValue()" (input)="onDurationInput($event)" />`
})
export class DurationInput implements FormValueControl<number | null> {
  // model value exposed to the form (in minutes)
  readonly value = model.required<number | null>();

  // 👇 raw UI value (string) <-> model value (minutes)
  readonly rawValue = transformedValue(this.value, {
    parse: (raw: string) => {
      // normalize user input
      const input = raw.trim().toLowerCase();
      if (input === '') return { value: null };

      // `20m` -> 20
      if (input.endsWith('m')) {
        const minutes = Number(input.slice(0, -1));
        if (Number.isNaN(minutes)) return { error: { kind: 'parse' } };
        return minutes < 0 ? { error: minError(0) } : { value: minutes };
      }

      // `1h` -> 60
      if (input.endsWith('h')) {
        const hours = Number(input.slice(0, -1));
        if (Number.isNaN(hours)) return { error: { kind: 'parse' } };
        const minutes = hours * 60;
        return minutes < 0 ? { error: minError(0) } : { value: minutes };
      }

      // anything else is a parse error
      return { error: { kind: 'parse' } };
    },
    // format minutes back to a user-facing value
    format: (value: number | null) => (value == null ? '' : `${value}m`)
  });

  onDurationInput(event: Event) {
    this.rawValue.set((event.target as HTMLInputElement).value);
  }
}

Nullable number inputs

Note that parse errors are now also integrated for native form inputs. For example, if a user types an unparsable value in a type="number" input, the model keeps its previous value and the field reports a parse error.

Signal Forms now better supports null with native number inputs: binding null clears the input, and when users clear the field, the model becomes null.

Our online workshop showcases a more complete example of transformedValue usage in the Signal Forms exercise 👀.

SignalFormControl for incremental migration

Angular v21.2 also introduces SignalFormControl in @angular/forms/signals/compat. It acts as a bridge between Signal Forms and classic Reactive Forms: you can define signal-form validation rules, while still plugging this new control into a FormGroup or FormArray.

protected readonly credentials = this.fb.group({
  login: ['', [Validators.required, Validators.minLength(3)]],
  // 👇 use a SignalFormControl in a classic FormGroup
  password: new SignalFormControl('', p => {
    required(p);
    minLength(p, 6);
  })
});

On the template side, you don't need to do anything special to bind a SignalFormControl with formControlName/formControl.

@let passwordCtrl = credentials.controls.password;
<input id="password" type="password" formControlName="password" />
@if (passwordCtrl.touched && passwordCtrl.hasError('required')) {
  <div>Password is required</div>
}

It also exposes a fieldTree so the control can be bound with [formField] in templates.

@let passwordCtrl = credentials.controls.password;
<input id="password" class="form-control" type="password" [formField]="passwordCtrl.fieldTree" />
@if (passwordCtrl.touched && passwordCtrl.hasError('required')) {
  <div id="password-required-error" class="invalid-feedback">Password is required</div>
}

This is particularly useful for bottom-up migration: you can migrate one control at a time instead of rewriting the whole form in one pass.

SignalFormControl would not be really "signal-friendly" if it did not use a signal: it in fact has a sourceValue property which is a signal of the control value, and can be used:

protected readonly passwordStrength = computed(() => {
  const password = this.passwordCtrl.sourceValue();
  // ...
});

SignalFormControl intentionally does not support some imperative AbstractControl APIs:

  • disable() / enable()
  • setValidators() / addValidators() / removeValidators() / clearValidators()
  • setAsyncValidators() / addAsyncValidators() / removeAsyncValidators() / clearAsyncValidators()
  • setErrors()
  • markAsPending()

State and validation are expected to be derived from signal rules instead.

Reactive validateStandardSchema()

validateStandardSchema() now supports signal-based schemas. You can pass a function that returns the schema, so validation can react to changing signals over time. For example, if minimumLength is a signal containing the minimum length, you can now write: validateStandardSchema(p, () => z.object({ name: z.string().min(minimumLength()) })).

Warning for rendered hidden fields

Angular now warns in dev mode when a field marked as hidden is still rendered in the DOM. In Signal Forms, hidden fields should be guarded in the template (typically with @if), so this warning helps catch accidental rendering of hidden controls:

NG01916: Field 'password' is hidden but is being rendered.
Hidden fields should be removed from the DOM using @if.

Animations

It is now possible to have nested animations, where a component with its own animations is used inside another component that also has animations. Previously, only the animations of the top-level component were working when components were destroyed, but with this change, animations defined in nested components will be properly triggered first.

Router

Angular now lets you configure trailing slash behavior through dedicated location strategies. You can choose to always write URLs with a trailing slash, or to always remove it:

bootstrapApplication(AppComponent, {
  providers: [
    // 👇 always write URLs with a trailing slash
    { provide: LocationStrategy, useClass: TrailingSlashPathLocationStrategy }
    // or never
    // { provide: LocationStrategy, useClass: NoTrailingSlashPathLocationStrategy }
  ]
});

canMatch now gets a partial route snapshot

Another useful router change is that canMatch now receives a third argument: a partial route snapshot (PartialMatchRouteSnapshot).

This addresses a long-standing limitation where canMatch had access to route and segments, but not to params like { userId: '123' }. With this snapshot argument, the guard can directly read params, queryParams, fragment, etc.

DevTools

Angular DevTools now visualizes resource() relationships in dedicated clusters in the signal graph. DevTools also adds dependency-path highlighting from the node details panel. You can now highlight:

  • upstream dependencies (what this node depends on)
  • downstream dependents (what depends on this node)

This can be helpful when debugging update propagation in large signal graphs.

Angular CLI

Unit tests

The CLI now supports ng add for Vitest browser providers:

  • @vitest/browser-playwright
  • @vitest/browser-webdriverio
  • @vitest/browser-preview

When used on a project already configured with the @angular/build:unit-test builder (and Vitest runner), the schematic installs the selected provider package plus required peer dependencies (like playwright or webdriverio).

So you can now run:

ng add @vitest/browser-playwright

The unit-test builder has a new headless option to force all configured browsers to run headless:

{
  "builder": "@angular/build:unit-test",
  "options": {
    "browsers": ["Chromium"],
    "headless": true
  }
}

This is an alternative to the hard-coded convention used until now, where you had to name the browser with a Headless suffix to run it headlessly (for example ChromiumHeadless).

Prettier

The CLI now has built-in Prettier integration!

New generated applications now include a .prettierrc file, with prettier a dev dependency. Files created/modified by schematics are automatically formatted when possible and ng update migrations now also run formatting on changed files.

This reduces formatting noise after ng generate and ng update.

Summary

This was a packed release for a minor version. Arrow functions and exhaustive @switch type-checking on the template side, the ChangeDetectionStrategy.Eager alias as the OnPush as default migration is coming, and a wave of Signal Forms improvements: FormRoot, transformedValue, SignalFormControl for incremental migration. Next version should be the v22 release.

Stay tuned!

All our materials (ebook, online training and training) are up-to-date with these changes if you want to learn more!


Photo de Cédric Exbrayat
Cédric Exbrayat

← Article plus ancien