Introduction
Angular 21 first introduced the pioneering Signal Forms, which are set to replace the familiar Reactive Forms and Template-Driven Forms. The Angular team has provided a range of options to simplify the migration process.
This article introduces the various basic concepts and explains the key steps involved in migrating existing forms. It also explores how AI can best support us in this process.
Why Signal Forms?
Currently, using forms alongside Signals is somewhat cumbersome. However, the new forms integrate the concepts seamlessly, enabling a purely declarative structure that is shorter and more comprehensible than before. Instead of Observables and Subscriptions, a declarative State is now used, which makes components clearer and easier to maintain. Furthermore, some potential memory leaks are eliminated and switching to Signals enables the new, optimised change detection to deliver improved performance.
Additionally, the new forms offer improved type safety, enabling errors to be identified earlier.
The starting Point
The migration starts with a basic order form based on Reactive Forms, which includes input fields for an email address and postal address.
1orderForm: FormGroup<OrderForm> = this.fb.group({
2 email: ['', [Validators.required, Validators.email]],
3 address: this.fb.group({
4 street: ['', Validators.required],
5 zip: ['', Validators.required]
6 })
7});
8
9isSubmitting = false;
10
11async onSubmit(): Promise<void> {
12 this.orderForm.markAllAsTouched();
13 if (this.orderForm.valid && !this.isSubmitting) {
14 this.isSubmitting = true;
15 await this.orderService.order(this.orderForm.value);
16 this.orderForm.reset();
17 this.isSubmitting = false;
18 }
19}
The corresponding HTML uses the standard directives:
1<form [formGroup]="orderForm" (ngSubmit)="onSubmit()">
2 <input [formControl]="orderForm.controls.email" />
3 @if (orderForm.controls.email.invalid && orderForm.controls.email.touched) {
4 <span class="error">Invalid email</span>
5 }
6 ...
7 <button type="submit" [disabled]="orderForm.invalid || isSubmitting">Submit</button>
8</form>
This is a basic working example. As can be seen in the code, you have to manually record and check whether a Submit operation is in progress. In this version, formControl only returns error codes, not messages, which makes managing and displaying the messages more difficult.
SignalFormControl
If this FormGroup is now to be extended by adding, for example, a telephone input field, Angular offers the option of building the new FormControls on a signal-based approach using the SignalFormControl. This allows the new API to be used without having to modify the existing base, thereby enabling a bottom-up migration.
1import { SignalFormControl } from '@angular/forms/signals/compat';
2import { required } from '@angular/forms/signals';
3
4readonly phoneNumber = new SignalFormControl('', field => {
5 required(field, { message: 'Phone number is required' });
6});
7
8orderForm = this.fb.group({
9 email: ['', [Validators.required, Validators.email]],
10 phoneNumber: this.phoneNumber,
11 address: this.fb.group({ ... })
12});
The second parameter for the SignalFormControl constructor is of particular interest here. It is a schema function that defines the validation rules for the field. The field in this context is a reference to the field itself, which can be used in the validation rules.
In the example, the telephone number is made a required field using required, and the corresponding error message is passed directly via the message property. As schema functions are reusable, this makes it easier to display consistent error messages in larger applications.
While this may seem like a fairly minor difference for individual fields at first, the benefits become much more apparent when used with entire forms.
The template for the existing fields remains unchanged; for the new field, formField is now used instead of formControl.
1<input type="tel" [formField]="phoneNumber.fieldTree" />
2@let phoneField = phoneNumber.fieldTree();
3@if (phoneField.touched() && phoneField.errors(); as errors) {
4 @for (error of errors; track error) {
5 <span class="error">{{ error.message }}</span>
6 }
7}
compatForm as Bridge
compatForm is the next logical step in the migration process and is often a good place to start. Using compatForm migrates the entire form while still allowing you to incorporate legacy FormControls. This is useful if Custom Controls have been used or if complex RxJS workflows are in place. Otherwise, you can migrate directly to pure Signal Forms.
Before migrating the form, we’ll add a Custom Component called ShippingSelectorComponent. The ControlValueAccessor interface allows you to link your own components to FormControls and facilitates data exchange with other components. This component allows you to select the delivery method and helps to illustrate the subsequent steps. This component is used as an example to demonstrate the use of compatForm.
1@Component({
2 selector: 'app-shipping-selector',
3 providers: [{
4 provide: NG_VALUE_ACCESSOR,
5 useExisting: forwardRef(() => ShippingSelectorComponent),
6 multi: true
7 }]
8})
9export class ShippingSelectorComponent implements ControlValueAccessor {
10 private value: ShippingOption | null = null;
11 private disabled = false;
12 private onChangeFn: (value: ShippingOption | null) => void = () => {};
13 private onTouchedFn: () => void = () => {};
14
15 writeValue(value: ShippingOption | null): void { this.value = value; }
16 registerOnChange(fn: (value: ShippingOption | null) => void): void { this.onChangeFn = fn; }
17 registerOnTouched(fn: () => void): void { this.onTouchedFn = fn; }
18 setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; }
19
20 onChange(newValue: ShippingOption): void {
21 this.value = newValue;
22 this.onChangeFn(newValue);
23 this.onTouchedFn();
24 }
25}
The current approach to implementing such a component involves a great deal of boilerplate code and requires a lot of extra effort, such as manually handling changes. Later sections demonstrate just how much shorter the modern approach is.
Using compatForm, the entire form can be migrated and the new ShippingSelectorComponent integrated without having to modify the Custom Control in any way. This allows Signal Forms to be used without having to adapt existing components.
1shippingControl = new FormControl('standard', {
2 nonNullable: true,
3 validators: Validators.required
4});
5
6initialState = {
7 email: '',
8 phoneNumber: '',
9 shipping: this.shippingControl,
10 address: { street: '', zip: '' }
11}
12
13orderModel = signal(this.initialState);
14
15orderValidationSchema = (schemaPath) => {
16 required(schemaPath.email, { message: 'Email is required' });
17 email(schemaPath.email, { message: 'Invalid email format' });
18 required(schemaPath.phoneNumber, { message: 'Phone number is required' });
19 required(schemaPath.address.street, { message: 'Street is required' });
20 required(schemaPath.address.zip, { message: 'Zip is required' });
21};
22
23orderSubmission = async (form) => {
24 await this.orderService.order(this.orderModel());
25 this.shippingControl.reset('standard')
26 form().reset(this.initialState);
27 return undefined;
28};
29
30orderForm = compatForm(this.orderModel, this.orderValidationSchema, {
31 submission: { action: this.orderSubmission }
32});
There is also a schema function here. This has been moved to the variable orderValidationSchema and can therefore be reused for multiple forms. In this case, the function references the entire form, so the whole validation process is defined as a single unit. As everything is built on Signals, conditional validation can also be incorporated without having to manage it separately.
This brings about a number of changes in the template. Instead of formGroup, formRoot is now used, replacing (ngSubmit) and applying the submission logic from the form declaration. Individual fields now use formField, as with SignalFormControl.
1<form [formRoot]="orderForm">
2 <input [formField]="orderForm.email" />
3 ...
The ShippingSelectorComponent is the exception here and continues to use the old syntax:
1<app-shipping-selector [formControl]="orderForm.shipping().control()" />
As the Signal Form now handles the submission directly, the status can be recorded automatically and can be easily retrieved using the submitting signal.
1@if (orderForm().submitting()) {
2 <span class="submitting">Submitting...</span>
3}
4<button type="submit" [disabled]="!orderForm().valid() || orderForm().submitting()">
5 Submit
6</button>
Pure Signal Forms
Just one step remains before the switch to pure SignalForms can be completed: the ShippingSelectorComponent needs to be migrated. The new FormValueControl interface is now available for this purpose, which eliminates all boilerplate code.
The NG_VALUE_ACCESSOR is no longer required, and Angular itself now manages the onTouched and onChanged methods. Additionally, the interface provides Signals that are linked to standard validators, such as minLength, thereby supporting interoperability with them.
For new Custom Controls, it is not necessary to implement all fields; only those that are actually needed need to be implemented. This makes new components incredibly short:
1export class ShippingSelectorComponent implements FormValueControl<ShippingOption> {
2 value = model<ShippingOption>('standard');
3 disabled = input<boolean>(false);
4}
Changes to the form are made using the value-Model, and Angular communicates these directly in the background.
Angular also provides a range of optional state properties that are managed automatically. For example, the disabled attribute can be used to determine whether the element is disabled or not, without any additional effort.
In the parent component, compatForm will be replaced by form and the model adjusted. Finally, the template will also be updated to use formField here.
1import { form } from '@angular/forms/signals';
2
3orderModel = signal({
4 email: '',
5 phoneNumber: '',
6 shipping: 'standard' as ShippingOption,
7 address: { street: '', zip: '' }
8});
9
10orderForm = form(this.orderModel, (schemaPath) => ... );
FormArrays
FormArrays require more attention during migration than other form fields, as it is not just the syntax that has changed.
With Reactive Forms, FormArrays were used as follows:
1get items(): FormArray<FormGroup<ItemForm>> {
2 return this.orderForm.controls.items;
3}
4
5addItem(): void {
6 this.items.push(this.fb.group({ name: ['', Validators.required], quantity: [1] }));
7}
8
9removeItem(index: number): void {
10 this.items.removeAt(index);
11}
Signal Forms use signals to indicate their state and must therefore be updated accordingly.
1addItem(): void {
2 this.orderForm.items().value.update(items => [...items, { name: '', quantity: 1 }]);
3}
4
5removeItem(index: number): void {
6 this.orderForm.items().value.update(items => items.filter((_, i) => i !== index));
7}
The biggest difference, however, lies in the validation, which is defined in the form using applyEach and applied to each field.
1orderForm = form(this.orderModel, (schemaPath) => {
2 applyEach(schemaPath.items, (item) => {
3 required(item.name, { message: 'Item name is required' });
4 required(item.quantity, { message: 'Quantity is required' });
5 });
6});
Differences in Tests
Surprisingly little changes during testing. A robust test suite focuses on user interactions and outcomes, rather than on implementation details.
1const button = spectator.query('button[type="submit"]') as HTMLButtonElement;
2expect(button.disabled).toBe(true);
The tests that require the most adaptation are those that use the state of the forms. For example, instead of control.value or control.valid, you must now use field().value() and field().valid() respectively. In addition, the test templates must also be updated to use the new directives.
Common Pitfalls
As well as the syntax itself, Signal Forms involve many other adjustments that may be unexpected during development.
Declarative State
In addition to validation, the rest of the form’s state is also declarative and is defined in centrally. Whereas disable and enable were previously called imperatively, the form now manages the status automatically. disabled, hidden and readonly can also be defined in this way.
1orderForm = form(this.orderModel, (schemaPath) => {
2 disabled(schemaPath.address, ({valueOf}) =>
3 valueOf(schemaPath.shipping) === 'pickup'
4 );
5});
valueChanges and statusChanges
A common pattern with Reactive Forms is to subscribe to the observables in a FormControl and thus react to changes.
With the new variant, you need to switch to effect or computed in these places. Overall, Signal Forms ensure that the majority of the logic is defined declaratively.
1// Reactive Forms
2this.total$ = this.form.valueChanges.pipe(map(value => calculateTotal(value)));
3
4// Signal Forms
5total = computed(() => calculateTotal(this.orderModel()));
CSS Classes
The familiar CSS classes, such as ng-valid and ng-touched, are no longer set by default. However, Angular allows you to enable these classes across your entire project and add your own.
1import {provideSignalFormsConfig} from '@angular/forms/signals';
2import {NG_STATUS_CLASSES} from '@angular/forms/signals/compat';
3
4bootstrapApplication(App, {
5 providers: [
6 provideSignalFormsConfig({
7 classes: {
8 ...NG_STATUS_CLASSES,
9 'ng-readonly': ({state}) => state().readonly(),
10 }
11 }),
12 ],
13});
Reset
form().reset() behaves slightly differently to formGroup.reset(). As before, the new version updates the touched and dirty states, but does not update any values.
To clear the fields, the values must be passed explicitly.
1form().reset({
2 email: '',
3 phoneNumber: '',
4 shipping: 'standard',
5 address: { street: '', zip: '' }
6});
AI-assisted migration
Migrations to new APIs often remain unresolved for a long time, creating technical debt and thus increasing the workload in the long run. Repetitive tasks, such as the migration of form fields, are well suited to AI support.
Conversational AI
Angular provides very detailed documentation on the migration and API of Signal Forms. There are examples illustrating the individual migration steps, and the relationships and rationale behind the interfaces are explained. With enough time, this allows you to build up a solid understanding.
However, chatbots face the problem that much of the data is out of date, meaning that responses are not based on the latest information. For example, the AI used a computed-signal to calculate the displayed value in a form, rather than using the new transformedValue method, which handles this task.
In some cases, the responses are simply incorrect, which is particularly common with new interfaces. The AI provides a seemingly correct answer based on old interfaces, but one that does not actually work.
However, if you ensure that the AI has access to up-to-date data, it can explain new concepts effectively and may even be able to migrate individual forms. It is important, though, to have an overview of the new API's capabilities and to verify the answers.
Agentic AI
To test the migration using agent-based AI, I have compiled key examples from a project and supplemented them further to cover as many use cases of Reactive Forms as possible. I have thus put together 22 examples and connected them to a test API to ensure testing is as close to real-world conditions as possible.
Among other things, the following aspects are included:
| Topic | Details / Features |
|---|---|
| Validation | Server-side, Cross-field, Schema with Zod |
| Dynamic Forms | FormArray, FormRecord, Conditional fields |
| Wizards & Forms | Multi-step wizards, Nested forms |
| Dropdowns | Dependent drop-downs, API integration, Autocomplete |
| Custom Controls | 5-star rating, File upload |
| Logic & HTTP | Complex RxJS composition, HTTP interceptor, Autosave and recovery, Form submission |
All examples are covered by tests designed to validate user interaction and the presentation of results. In other words, rather than focusing on the internal state of the components, the tests focus on what users actually see. This ensures that all elements behave exactly as they did before migration.
I carried out the migration twice using agent-based AI. On both occasions, the version was updated from Angular 20 to 22, and all instances of Reactive Forms were replaced with Signal Forms.
Both runs were carried out using Claude Opus 4.8. In the first run, the codebase was migrated without any additional tools. At first glance, the result looked promising — all tests passed and the application built without errors. Visually, the examples also appeared unchanged. Upon closer inspection, however, a number of differences and serious problems became apparent.
Although Claude initially consulted the official documentation, it seems that most of the problems stem from an incomplete understanding of the API. Among other things, the FormField interface is missing from the Custom Controls, validation was implemented in some places using computed signals, and effects were used instead of transformedValue to set new values.
Furthermore, Claude appears to have made arbitrary adjustments as the API became more complex. For example, a field for age has been replaced by a biography field, error messages have been reworded or removed, and several types now contain a name instead of a UUID. In addition, there are some instances that have not been correctly typed and fall back to any.
However, the migration has also resulted in more serious errors. A race condition during automatic saving led to data loss. In one example, a chaining of effects created an infinite loop that caused the browser tab to crash. Furthermore, the submission does not work in any of the examples. When attempting to rectify the issues, Claude found the submission issue the most challenging and built several cumbersome solutions until a manual reference to the new API was provided.
Overall, the majority of the problems appear to stem from a lack of knowledge of the API and best practices.
Conveniently, the Angular team offers a virtually tailor-made solution for this: the Angular CLI MCP Server. This provides two essential tools for working with AI. Firstly, direct access to the documentation via search_documentation and, secondly, access to best practices via get_best_practices.
To test this, a second run was carried out with Claude, this time with access to these tools.
Not only did the second run use 60% fewer tokens, it also resolved almost all of the aforementioned issues. Thanks to a better understanding of the interfaces, Claude avoided complex and error-prone structures in effects and used the specific functions designed for this purpose instead.
Interestingly, the AI tends to use validateTree in too many places. For example, for cross-field validation, even though the official documentation specifically advises against this. In some cases, it even uses it for individual fields. Functionally, this has no impact, but it is messy code.
In the second run, too, Claude failed to migrate the Submission, and no forms could be submitted. However, with the help of the documentation, Claude was able to correct this straight away and even used the new submitting-signals in some places. With some refinements to the tests, this problem was resolved.
Apart from that, all examples are functionally identical to the solution using Reactive Forms. The agent-based AI was also able to migrate complex examples and specific use cases, and largely identified the correct functions in the API.
Conclusion
Signal Forms offer compelling advantages — the declarative approach results in less code that is easier to understand. This is particularly noticeable with Custom Controls.
Agent-based AI can handle the bulk of the migration using Angular’s MCP. A comprehensive and behaviour-oriented test suite builds confidence in the solution. Nevertheless, the AI’s results should be verified. It is necessary to familiarise yourself with the new API independently to ensure clean and sustainable solutions.
As a first step, migrate existing forms using compatForm to gradually familiarise yourself with the new API.
More articles in this subject area
Discover exciting further topics and let the codecentric world inspire you.
Blog author
Jasper Houben
Do you still have questions? Just send me a message.
Do you still have questions? Just send me a message.