20 Nov 2025
7 min

Angular 21 – what’s new ?

Another Angular major update has been released so it is high time to go over it. Let’s analyse what has been changed and how we can use it in our daily work. 

Zoneless is a default.

As it was mentioned in a previous article, with Angular 20.2, the zoneless change detection has reached a stable state. Building on this, Angular 21 introduces zoneless as the default for new applications, and the schematics to assist with migrating existing Angular projects to zoneless have been prepared.

We hope that every Angular developer is familiar with Angular Signals. If you are not, it is high time to catch up — you can start by reading our article: https://angular.love/angular-signals-a-new-feature-in-angular-16

You probably also know Angular Forms. If not, check out https://angular.love/typed-forms-2.It is crucial to understand how signals work before diving into this section. Assuming you already do, let’s explore a new Angular feature called

Signal Forms

[Master revolutionary Signal Forms! Write code without takeUntil(), leverage automatic type inference for forms, and build incredibly performant applications. Workshop date: 20.01.2026]

Let’s create a very simple reactive form:

export class App implements OnInit {
 private readonly _fb = inject(FormBuilder);
 protected form!: PersonForm;


 ngOnInit() {
   this.initForm();
 }


 protected onSubmit() {
   if (this.form.valid) {
     console.log('Form submitted:', this.form.value);
   }
 }


 private initForm() {
   this.form = this._fb.group({
     name: this._fb.nonNullable.control('', 
       [ 
         Validators.required,
         Validators.minLength(3)
       ]
     ),
     surname: this._fb.nonNullable.control('',
       [ 
         Validators.required,
         Validators.maxLength(10)
       ]
     ),
     telephoneNumber: this._fb.control(
       null,
       Validators.required
     ),
   });
 }

And next, we can use it in our template:

template: `
   <form [formGroup]="form" (ngSubmit)="onSubmit()">
     <div>
       <label>
         Name:
         <input type="text" formControlName="name" />
       </label>
       @if(form.controls.name.invalid && form.controls.name.touched) {
         <p>Name is required</p>
       }
       </div>


     <div>
       <label>
         Surname:
         <input type="text" formControlName="surname" />
       </label>
       @if(form.controls.surname.invalid && form.controls.surname.touched) 
       {
         <p>Surname is required</p>
       }
     </div>


     <div>
       <label>
         Telephone Number:
         <input type="number" formControlName="telephoneNumber"/>
       </label>
       @if(form.controls.telephoneNumber.invalid && 
           form.controls.telephoneNumber.touched
       ) {
         <p>Telephone number is required</p>
       }


     </div>
     <button type="submit" [disabled]="form.invalid">Submit</button>
   </form>


   <pre>{{ form.value | json }}</pre>
 `,

As we can see, this is a very simple example. We’ve initialized a basic reactive form with three controls: name, surname, and telephone number. In the template, we check whether the user has interacted with a control and, if so, whether the validation conditions are satisfied. If they’re not, we display a simple error message. Let’s see how this can be modified and improved in the new version of Angular.

First, let’s update our component class:

protected readonly person = signal<PersonForm>({
   name: '',
   surname: '',
   telephoneNumber: null
 })
protected readonly personForm = form(this.person);

As you can see, we’ve created a simple signal that serves as the model for our new signal form. We’ve also removed the OnInit method — it’s not needed. The form() function is a new API introduced in the latest version of Angular. Keep in mind that when we update a value in personForm, the value in our form model is automatically updated as well:

changePersonName(value: string) {
   this.personForm.name().value.set(value);
   console.log(this.person()); // {name: 'John', surname: '',    telephoneNumber: null}
 }

Let’s move on to the template to see how we can make the form work within it. Before updating your form, make sure that the Control directive — which is responsible for binding form fields to UI components — is properly imported. Once that’s done, we can update the form as follows:

template: `
   <form (ngSubmit)="onSubmit()">
     <div>
       <label>
         Name:
         <input [control]="personForm.name" type="text"/>
       </label>
     </div>


     <div>
       <label>
         Surname:
         <input [control]="personForm.surname" type="text"/>
       </label>
     </div>


     <div>
       <label>
         Telephone Number:
         <input [control]="personForm.telephoneNumber" type="number" />
       </label>
     </div>
     <button type="submit"    [disabled]="personForm().invalid()">Submit</button>
   </form>


   <pre>{{ personForm().value() | json }}</pre>
 `,

It’s as simple as the example above. You’ve probably noticed that error handling has been removed — we did this intentionally to demonstrate how the mechanism has been changed. We can now handle errors in a simple way by iterating through the possible errors and displaying those that exist.

protected readonly personForm = form(this.person, (path) => {
     required(path.name),
     required(path.surname),
     minLength(path.name, 3),
     maxLength(path.surname, 40)
});

And that’s how we can display error:

<div>
       <label>
         Name:
         <input [control]="personForm.name" type="text" />
       </label>
       @for(err of personForm.name().errors(); track $index) {
         @if(err.kind === 'required') {
           <p>Name is required</p>
         }
      }
     </div>

You might wonder what’s actually happening inside personForm. In fact, there’s no magic here — our validation logic is simply wrapped with a schema function. We also use new validation utilities such as required(). All we need to do is provide the correct path to each validation function, which we can obtain from the argument passed into the schema function.

Keep in mind that Signal Forms are still in the experimental phase, so both the API and behavior may change before the stable release.

Simple Changes are a generic now

In the latest Angular 21 release, SimpleChanges has been updated to be a generic type. It means that we can now explicitly define the type of data that each @Input() property carries, which allows TypeScript to enforce stronger type checking inside the ngOnChanges lifecycle hook. Previously, SimpleChange used any type for previousValue and currentValue, which meant that developers had no compile-time guarantees about the types of the values being passed. You can see below how this works right now. 

export interface User {
  userName: string;
  age: number;
}

@Component({
 //…//
})
export class App {
  @Input({required: true}) userName!: string;
  @Input({required: true}) age!: number;


  ngOnChanges(changes: SimpleChanges<User>) {
    if (changes.age) {
      const newAge = changes.age.currentValue;
      const oldAge = changes.age.previousValue;


      const diff = newAge - oldAge;


      console.log(`Age increased by ${diff} years`);
    }
  }
}

HttpClient provided by default

Along with the introduction of the latest version of Angular, we will no longer need to provide the HttpClient in our application. It will be provided by default. So, when creating our configuration object, you are allowed to skip providing the HttpClient if you want to.

// import { provideHttpClient } from `@angular/common`


export const appConfig: AppConfig = {
  providers: [
   ...anotherProviders,
   // provideHttpClient()
  ]
}

NgClass directive to style binding new schematics are on the board

As we know, using ngClass is not recommended; however, you are still allowed to use it in our app. It is a good practice to avoid using this directive. To make our work easier and faster, the Angular team has prepared a migration schematic that automatically converts all ngClass usages to class bindings.

Here’s what it looks like before migration:

@Component({
 //…//
 imports: [NgClass],
 template: `
   <button [ngClass]="{
             'isNew': isNew()
   }">Click me</button> //before migration
 `,
})
export class App {
 protected readonly isNew = signal(true);
}

And after migration:

@Component({
 //…//
 // imports: [NgClass] - is’s no longer needed
 template: `
   <button [class]="{
             'isNew': isNew()
   }">Click me</button> //after migration
 `,
})
export class App {
 protected readonly isNew = signal(true);
}

As you can see, we no longer need to import NgClass, which makes our bundle smaller and our code shorter — and therefore more pleasant to read. This schematics is run by following command:

ng generate @angular/core:ngclass-to-class

Planning your next upgrade or just trying to stay current? We created a complete overview of Angular 14 to the latest version to help developers and decision-makers understand what changed and why it matters. Download “The Ultimate Guide to Angular Evolution” for free 

Migration of NgStyle directive to style bindings new schematics 

Similarly to the ngClass directive migration, a schematic has been prepared to migrate the ngStyle directive to style bindings.

As before, here is an example before and after the migration:

@Component({
 //…//
 imports: [NgStyle],
 template: `
   <button [ngStyle]="{
             'border-color': borderColor(),
   }">Click me</button> //before migration
 `,
})
export class App {
  readonly theme = input.required<ColorTheme>();
  protected readonly borderColor = computed(() => this.theme() ===   'primary' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(0, 0, 0, 0.5)' ));
}

After migration:

@Component({
 //…//
 // imports: [NgStyle], - is’s no longer needed
 template: `
   <button [style]="{
             'border-color': borderColor(),
   }">Click me</button> //before migration
 `,
})
export class App {
  readonly theme = input.required<ColorTheme>();
  protected readonly borderColor = computed(() => this.theme() ===   'primary' ? 'rgba(0, 0, 0, 0.1)' : 'rgba(0, 0, 0, 0.5)' ));

Just like in the previous example, we no longer need to import the directive.

You can run this migration by typing the following command:


ng generate @angular/core:ngstyle-to-style

KeyValue pipe supports optional keys

Since the recent release of Angular, it’s now possible to use the keyvalue pipe on objects with optional keys without causing any TypeScript errors. This change improves type safety and makes it easier to work with data models that don’t always define all of their properties.

export interface User {
  name: string;
  surname?: string;
  age?: number;
}


@Component({
  selector: 'app-root',
  imports: [KeyValuePipe],
  template: `
    @for (prop of user | keyvalue; track $index) {
      <p>Property key: {{ prop.key }}, property value: {{ prop.value }}</p>
    }
  `,
})
export class App {
  protected readonly user: User = {
    name: 'John',
    surname: 'Doe',
    age: 37
  };
}

[Master revolutionary Signal Forms! Write code without takeUntil(), leverage automatic type inference for forms, and build incredibly performant applications. Workshop date: 20.01.2026]

Enhanced HttpResponse and HttpErrorResponse

In the latest version of Angular, the HttpResponse and HttpErrorResponse classes introduce a new responseType property. This property exposes the underlying Fetch API response type (for example, 'basic’, 'cors’, 'opaque’, or 'opaqueredirect’).

This enhancement makes it easier to diagnose CORS-related issues and provides better insight into the security context of HTTP responses, while keeping the existing HttpClient behavior unchanged.

@Injectable({ providedIn: 'root' })
export class DataService {
  private readonly _httpClient = inject(HttpClient);


  getData(): Observable<HttpResponse<any>> {
    return this.http.get('/api/data', { observe: 'response' }).pipe(
      tap(response => {
        console.log('Response type:', response.responseType);


        if (response.responseType === 'opaque') {
          console.warn('CORS issue detected — response is opaque.');
        }
      })
    );
  }
}

Summary

If you’re running an Angular application and haven’t yet upgraded to Angular 21, this is a great time to consider doing so. Version 21 introduces several meaningful enhancements — most notably the arrival of Signal Forms. These changes significantly improve the developer experience while also improving performance in many scenarios. For a deeper dive into what’s addressed in this release and how to make the most of it in your project, be sure to explore our earlier coverage of Angular’s evolution and new features.

Share this post

Sign up for our newsletter

Stay up-to-date with the trends and be a part of a thriving community.