Angular Signal Forms - Part 1
In Angular v21, developers will have a new way, experimental for now, to build forms: Signal Forms.
After years of working with template-driven forms (ngModel)
and reactive forms (formGroup/formControl),
we now have a third approach that's entirely based on signals,
available in the @angular/forms/signals package.
This is the first part of our series on Angular Signal Forms. In this article, we'll explore the basics: creating forms, handling submissions, and adding validation.
Let's dive in! 🚀
Creating a form with form()
As this new way is based on signals,
the first thing to do in your component is to define a signal that will hold
the value of the form.
Then you can create a FieldTree to edit the value of this signal,
using the form() function from the @angular/forms/signals package.
Here I want to write a Login component,
where the user inputs their credentials:
// 👇 signal to hold the form credentials
private readonly credentials = signal({
login: '',
password: ''
});
// 👇 form created from the credentials signal (type is `FieldTree`)
protected readonly loginForm = form(this.credentials);
Then we need to bind each input/select/textarea to a field in the form.
This is done using a single directive, Field:
<form (submit)="authenticate($event)">
<label for="login">Login</label>
<input id="login" [field]="loginForm.login" />
<label for="password">Password</label>
<input id="password" [field]="loginForm.password" />
<button type="submit">Log in</button>
</form>
That's it!
With just the [field] directive,
your inputs are automatically bound to the form fields.
A change on an input updates the corresponding property of the signal,
and a change to the signal updates the input value.
Submitting the form with submit()
To submit the form, you can listen to the native submit event on the form element
and call a method of your component.
In this method, you can then use the submit() function from the @angular/forms/signals package
to handle the submission of the form.
This function:
- Marks all fields as touched
- Checks the form validity
- If valid, calls your provided async action with the form as an argument
The argument is the same object, a FieldTree,
as the loginForm returned by the form() function.
The FieldTree object is a key concept in signal forms.
Your loginForm is a FieldTree, and so are loginForm.login and loginForm.password.
A FieldTree, like a Signal, is an object that is also a function.
If you call it (loginForm()),
it returns a FieldState object with several signal properties
that represent the state of the field:
value(): the current value of the fieldtouched(): whether the field has been touched or notdirty(): whether the field is pristine or notdisabled(): whether the field is disabled or notdisabledReasons(): if the field is disabled, the reasons whyhidden(): whether the field is hidden or notreadonly(): whether the field is readonly or notsubmitting(): whether the field is being submitted
Some properties are related to validation, a topic we cover below:
pending(): whether the field is pending (async validators running)valid(): whether the field is valid (all validators passed). Note thatvalid()is false until all async validators are done.invalid(): whether the field is invaliderrors(): the errors of the field (if any)errorSummary(): the errors of the field and its sub-fields (if any)
It also has a few methods to change the state of the field:
reset()to mark the field as pristine and untouched (but does not reset the value)markAsTouched()/markAsDirty()to mark the field as touched/dirty.
We can also grab the FieldState of a sub-field using loginForm.login() for example,
and each property then represents the state of that sub-field (its value, dirtiness, etc).
So, to come back to our form submission, we can write:
protected async authenticate(event: SubmitEvent) {
// 👇 prevents the default browser behavior
event.preventDefault();
// 👇 submits the form if it's valid by calling the authenticate method (a promise)
await submit(this.loginForm, form => {
const { login, password } = form().value();
return this.userService.authenticate(login, password);
});
}
Note that the function that you pass to submit() must return a Promise.
So if your service returns an Observable,
you'll have to convert it to a promise using, for example,
the firstValueFrom() function from rxjs.
Let's now add some validation to our form.
Adding validation with built-in validators
Angular provides a validation system based on functions that can be used to programmatically constrain the value of a field.
As with the previous form systems, there are two types of validators:
- synchronous validators, that run immediately
- asynchronous validators, that return a
Promiseand only run if all the synchronous validators on the field pass
The framework itself provides some built-in synchronous validators, the same as those it provides for reactive and template-driven forms:
required(field)to mark a field as requiredminLength(field, length)to set a minimum length (for strings and arrays)maxLength(field, length)to set a maximum length (for strings and arrays)min(field, min)to set a minimum value (for numbers)max(field, max)to set a maximum value (for numbers)email(field)to validate an email addresspattern(field, pattern)to validate a value against a regex pattern
The functions take the field to validate as the first argument, and other arguments depending on the validator.
Let's build another Register form,
allowing the user to create an account:
protected readonly accountForm = form(
signal({
email: '',
password: ''
}),
// 👇 add validators to the fields, with their error messages
form => {
// email is mandatory
required(form.email, { message: 'Email is required' });
// must be a valid email
email(form.email, { message: 'Email is not valid' });
// password is mandatory
required(form.password, { message: 'Password is required' });
// should have at least 6 characters
minLength(form.password, 6, {
// can also be a function, if you need to access the current value/state of the field
message: password => `Password should have at least 6 characters but has only ${password.value().length}`
});
}
);
Each validator will then run when the value of the field changes.
If the validation fails, a ValidationError is added to the errors() property of the field.
A ValidationError is an object with several properties:
kind: the kind of error (for examplerequired,minLength, etc)field: the field that caused the errormessage: an optional human-readable message. There is no message by default, you need to provide it yourself when applying the validator if you want one.
Some validators can add more properties to the error,
for example, the minLength validator adds a minLength property to the error,
so you can access it when displaying the error message.
These errors can be displayed in the template
by iterating over the errors() property of the field:
<input id="email" [field]="accountForm.email" />
@let email = accountForm.email();
@if (email.touched() && !email.valid()) {
<div id="email-errors">
@for (error of email.errors(); track error.kind) {
<div>{{ error.message }}</div>
}
</div>
}
The built-in validators do more than they used to:
they also add a property on the field.
This property can be useful to know if a validator is applied to a field.
For example, in the template, you can add an asterisk next to the label of required fields:
<label for="email">
<span>Email</span>
@let isEmailRequired = accountForm.email().metadata(REQUIRED);
@if (isEmailRequired()) {
<span>*</span>
}
</label>
The metadata(REQUIRED) method returns a signal,
and its value can be true or false.
But some validators store additional details in this metadata.
For example, the signal returned by metadata(MIN_LENGTH) contains the minimum length.
Standard schema validation with validateStandardSchema()
In addition to the previous built-in validators, signal forms offer a new way to define the field's constraints, by using a schema validation.
A few libraries have made this popular lately, like Zod or Valibot.
Both allow you to define a schema to represent your data,
using functions to define the type of each field,
and to add constraints to these fields.
These libraries and others even joined their effort to define a standard:
Standard Schema,
which provides a common interface, called StandardSchemaV1, that libraries can use.
Angular relies on this standard
and offers a validateStandardSchema() function
which accepts such a StandardSchemaV1 schema.
This schema can be defined with whatever library you prefer.
Let's use Zod to define a validation schema for our Register form:
(form) => {
validateStandardSchema(
form,
z.object({ email: z.email(), password: z.string().min(6) })
);
};
Here, we don't define error messages.
The errors will be generated automatically by the validateStandardSchema() function
which returns errors of type StandardSchemaValidationError.
These errors have the same kind, field properties
as the previous ValidationError,
but also an issue property containing a message.
You can display it like this:
<input id="email" [field]="accountForm.email" />
@let email = accountForm.email();
@if (email.touched() && !email.valid()) {
<div id="email-errors">
@for (error of email.errors(); track error.kind) {
@if (isStandardSchemaError(error)) {
{{ error.issue.message }}
}
}
</div>
}
with the help of the following type-guard:
isStandardSchemaError(error: ValidationError): error is StandardSchemaValidationError {
return error.kind === 'standardSchema';
}
For example, if you input a too short password, you'll get an error like this:
Too small: expected string to have >=6 characters
This can be customized as well,
as Zod allows defining error messages when defining the schema,
using z.string().min(6, { error: issue => `Password should have at least ${issue.minimum} characters` }) for example.
What's Next?
We've covered the fundamentals of Angular Signal Forms: creating forms, handling submissions, and adding basic validation with built-in validators or using schema validation with libraries like Zod.
In the next part, we'll explore more advanced topics: like creating custom validators, cross-field validation, and asynchronous validation.
Stay tuned for Part 2!
All our materials (ebook, online training and training) are up-to-date with these changes if you want to learn more!
← Article plus ancien
What's new in Angular 20.2?
Étiquettes
Nos livres en vente


Prochaines sessions de formation
- Du 17 au 20 nov. 2025Angular : de Ninja à Héros (à distance)
- Du 1 au 4 déc. 2025Vue : de Zéro à Ninja (à distance)
- Du 8 au 11 déc. 2025Angular : de Zéro à Ninja (à distance)
- Du 19 au 22 janv. 2026Angular : de Ninja à Héros (à distance)
- Du 9 au 12 févr. 2026Vue : de Zéro à Ninja (à distance)
- Du 2 au 5 mars 2026Angular : de Zéro à Ninja (à distance)
