Angular has a new forms API called Signal Forms, imported from @angular/forms/signals. It is still experimental, but I combined it with Ionic Framework and it worked fine—here are the migration patterns I used.
https://angular.dev/essentials/signal-forms
What are Signal Forms?
Signal Forms is Angular's new form management API. Unlike template-driven forms (FormsModule + ngModel) or reactive forms (ReactiveFormsModule), it takes a Signal-based approach.
With traditional ngModel forms, two-way binding managed values. With Signal Forms, I define the model with signal() and build the form tree with form(). Validation can be schema-based, so separation of template and logic is clearer.
Basic migration pattern
Migration was simple in practice. First, template-driven form style:
import { FormsModule } from '@angular/forms';
@Component({
imports: [FormsModule, IonInput],
})
export class SigninPage {
email = '';
password = '';
}
<form (submit)="submit()">
<ion-input
[(ngModel)]="email"
name="email"
[required]="true"
></ion-input>
<ion-input
[(ngModel)]="password"
name="password"
[required]="true"
></ion-input>
</form>
Migrated to Signal Forms:
import { form, FormField, required } from '@angular/forms/signals';
interface ILogin {
email: string;
password: string;
}
@Component({
imports: [FormField, IonInput],
})
export class SigninPage {
readonly loginModel = signal<ILogin>({ email: '', password: '' });
readonly loginForm = form(this.loginModel, (schemaPath) => {
required(schemaPath.email);
required(schemaPath.password);
});
}
<form novalidate (submit)="submit($event)">
<ion-input
[formField]="loginForm.email"
></ion-input>
<ion-input
[formField]="loginForm.password"
></ion-input>
</form>
Summary of changes: define the model with signal() and the form with form(). In the template, replace [(ngModel)] with [formField]; name is unnecessary. Remove [required]="true" from the template and define required() in the schema.
Important: do not use undefined in initial model values. Signal Forms does not support undefined; the FormField directive throws TypeError: this.formField(...) is not a function. Use null for empty values.
// NG: undefined cannot be used
readonly optionsModel = signal<IOptions>({
measureType: undefined,
measureSize: undefined,
});
// OK: use null
readonly optionsModel = signal<IOptions>({
measureType: null,
measureSize: null,
});
Accessing and updating values
Access patterns change too. With Signal Forms I can read from the model directly or from form fields.
// Read directly from the model
const { email, password } = this.loginModel();
// Read from the form field
const email = this.loginForm.email().value();
Updates work similarly—from the model or from individual fields.
// Update the entire model
this.loginModel.update((m) => ({ ...m, email: 'new@example.com' }));
// Update an individual field
this.loginForm.email().value.set('new@example.com');
undefined is not allowed on update either—not only initial values. If local storage or API data contains undefined, convert to null before setting the model.
async load() {
const options = await this.storage.get('printOptions');
if (options) {
// Convert undefined to null before setting it on the model
if (options.measureType === undefined) {
options.measureType = null;
}
if (options.measureSize === undefined) {
options.measureSize = null;
}
this.optionsModel.set(options);
}
}
When migrating existing apps, check local storage and API responses for undefined and add migration handling as needed.
Form submit handler changes
On submit, call event.preventDefault().
submit(event: Event) {
event.preventDefault();
const { email, password } = this.loginModel();
// Submit
}
In the template, add novalidate to <form> and pass $event to the handler.
<form novalidate (submit)="submit($event)">
novalidate disables browser-native validation. HTML5 validates elements with required, min, max, etc. If that stays enabled while Signal Forms controls validation, the browser may show its own errors or block submit unexpectedly.
Because Signal Forms defines validation in a schema, running browser validation in parallel can cause surprises. novalidate centralizes control on Signal Forms.
Validation
Signal Forms defines validation in a schema. Helpers include required(), minLength(), min(), max(), and readonly().
readonly optionsForm = form(this.optionsModel, (schemaPath) => {
min(schemaPath.printNum, 1);
max(schemaPath.printNum, 40);
min(schemaPath.fontSize, 8);
max(schemaPath.fontSize, 14);
});
Read-only fields belong in the schema, not as [readonly]="true" in the template.
readonly generatedForm = form(this.generatedModel, (schemaPath) => {
readonly(schemaPath.productName);
readonly(schemaPath.categoryName);
});
Notes with Ionic
One caveat combining with Ionic Framework. On standard HTML <input>, [required] and [min] conflict with formField and error:
NG8022: Setting the 'required' attribute is not allowed on nodes using the '[formField]' directive
On Ionic <ion-input>, this error does not appear. ion-input is a Web Component with different attribute handling. With Ionic, leaving [required] in the template may still work, but defining rules in the schema is more consistent.
Signal Forms reflects schema required() and readonly() onto DOM elements with [formField] as required and readonly attributes. Ionic components watch these; <ion-input> and <ion-textarea> propagate them to internal native <input> and <textarea>. Defining required() in the schema means Ionic handles it without [required]="true" in the template.
CSS updates
Removing [required] from the template may require updating CSS selectors—for example, badges on required fields.
// Before
*[required] label .label-text::before {
content: 'Required';
}
// After - use the :has() pseudo-class
*:has(.native-wrapper > *[required]) label .label-text::before {
content: 'Required';
}
The :has() pseudo-class applies styles when a child has a required attribute.
Summary
Signal Forms is Angular's new form management API. Still experimental, but it worked fine with Ionic Framework. Migration is simple: signal() + form(), swap [(ngModel)] for [formField].
Schema-based validation clarifies template vs logic separation. The undefined constraint means watch data migration when moving existing apps. When the API stabilizes, full adoption may be worth considering.
See you next time.