
Angular Signal Forms Part 1: Getting Started with the Basics
Oct 13, 2025
Angular introduces Signal Forms with Version 21.0.0-next.2, an experimental but promising approach to form handling that leverages Angular's reactive Signal primitive. This new API offers a declarative way to build forms with full control over the data model and built-in schema validation. In this first part of our three-part series, we'll cover the fundamentals you need to get started with Signal Forms.
⚠️ Experimental Feature: Signal Forms are currently an experimental feature in Angular. The API and functionality may change in future releases.
What Makes Signal Forms Different
Signal Forms represent a paradigm shift from Angular's existing form approaches of Template-Driven and Reactive Forms. The new approach follows three core principles:
- Full data model control: Form data is managed as a signal that we create, control, and can update directly at any time.
- Declarative logic: Validation logic is described through code in a reusable schema.
- Structural mapping: The field structure mirrors the data structure 1:1. It is not necessary to create the form model manually, but it is automatically derived from the data model.
Setting up the Data Model
The first step in creating a Signal Form is to define our data model. For this blog post, we will create a user registration form. A TypeScript interface defines all the fields we need: New users can provide name and age, multiple email addresses, and choose whether to subscribe to a newsletter via a checkbox. Finally, they must agree to the terms and conditions.
export interface RegisterFormData {
username: string;
age: number;
email: string[];
newsletter: boolean;
agreeToTermsAndConditions: boolean;
}
Next, we create a signal property containing our initial form state.
In this example, we keep the initialState
as a separate constant before, so we can re-use it later for resetting the form data after submission.
Of course, it is also possible to define the initial state directly inlined when creating the signal.
const initialState: RegisterFormData = {
username: '',
age: 18,
email: [''],
newsletter: false,
agreeToTermsAndConditions: false,
};
@Component({ /* ... */ })
export class RegistrationForm {
protected readonly registrationModel = signal<RegisterFormData>(initialState);
}
This signal serves as our single source of truth for all form data. It remains reactive and automatically synchronizes with any changes made through the form fields.
Creating the Field Structure
Now that we have our data model defined, the next step is to create the field structure that connects our data to the form.
Angular provides a form()
function to create a field tree that derives its structure from the data.
The result is a FieldTree
object that mirrors our data structure and maintains metadata for each field node.
import { form } from '@angular/forms/signals';
// ...
@Component({ /* ... */ })
export class RegistrationForm {
protected readonly registrationModel = signal<RegisterFormData>(initialState);
protected readonly registrationForm = form(this.registrationModel);
}
Accessing Field Properties
This form model structure allows us to navigate through our form field paths exactly like we would navigate through our data structure. Using this object, we can access individual fields and their reactive properties:
// Access field value
console.log(this.registrationForm.username().value()); // current username value
// Access field states
console.log(this.registrationForm.username().valid()); // validation status
console.log(this.registrationForm.username().touched()); // interaction status
console.log(this.registrationForm.username().errors()); // validation errors
Each nested call returns another FieldTree
that represents the corresponding part of the form.
We can call each FieldTree
as a function to receive a FieldState
object.
It provides several reactive properties that we can use in our templates and component logic:
State | Type | Description |
---|---|---|
value |
Signal<T> |
current value of this part of the field tree |
valid |
Signal<boolean> |
true if the field passes all validations |
touched |
Signal<boolean> |
true if the user has interacted with the field |
errors |
Signal<ValidationError[]> |
array of validation errors |
pending |
Signal<boolean> |
true if async validations are running |
disabled |
Signal<boolean> |
true if the field is disabled |
disabledReasons |
Signal<string[]> |
array of reasons about the disabled state |
hidden |
Signal<boolean> |
true if the field is semantically hidden |
It is important to stay aware of the difference between FieldTree
and FieldState
:
While FieldTree
represents the structure and metadata of the form, FieldState
provides the current state and value of a specific field.
Once we call a FieldTree
as a function, we get a FieldState
as the result.
Connecting Fields to the Template
Now that we have our form structure in place, we need to connect it to our HTML template to create functional input fields with reactive data binding.
Signal Forms use the Control
directive to bind form fields to HTML input elements.
To use the directive, we need to import it first.
In our example, we also import JsonPipe
so we can use it in our template to display the current form value.
import { JsonPipe } from '@angular/common';
import { /* ... */, Control } from '@angular/forms/signals';
// ...
@Component({
selector: 'app-registration-form',
imports: [Control, JsonPipe],
templateUrl: './registration-form.html',
})
export class RegistrationForm {
// ...
}
The Control
directive works directly with all standard HTML form elements like <input>
, <textarea>
, and <select>
.
Let's start with a basic template that connects some of our form fields: We apply the directive to the HTML element by using the [control]
property binding.
On the right side of the binding, we pass the corresponding FieldTree
from our form structure.
Notice, that we also use the form attribute novalidate
: It disables the native browser field validation.
We will handle validation later by using a form schema.
<form (submit)="submitForm($event)" novalidate>
<div>
<label for="username">Username</label>
<input id="username" type="text" [control]="registrationForm.username" />
</div>
<div>
<label for="age">Age</label>
<input id="age" type="number" [control]="registrationForm.age" />
</div>
<div>
<label for="newsletter">Subscribe to newsletter</label>
<input
id="newsletter"
type="checkbox"
[control]="registrationForm.newsletter"
/>
</div>
<button type="submit">Register</button>
</form>
<!-- Debug output to see current form data -->
<pre>{{ registrationModel() | json }}</pre>
<pre>{{ registrationForm().value() | json }}</pre>
We have now connected each input to its corresponding field in our form structure.
The Control
directive handles the two-way data binding automatically, keeping our data model synchronized with user input.
The form model automatically synchronizes with the data signal: To read the value, we can use the signal as well as the FieldState
with its value
property.
Working with Arrays
For our email array, we need to handle dynamic addition and removal of fields:
The registrationForm.email
field returns an array of FieldTree
objects that we can iterate over using @for()
.
<!-- ... -->
<fieldset>
<legend>
E-Mail Addresses
<button type="button" (click)="addEmail()">+</button>
</legend>
<div>
@for (emailField of registrationForm.email; track $index) {
<div>
<div role="group">
<input
type="email"
[control]="emailField"
[ariaLabel]="'E-Mail ' + $index"
/>
<button type="button" (click)="removeEmail($index)">-</button>
</div>
</div>
}
</div>
</fieldset>
<!-- ... -->
As you may have noticed, we also added two buttons for adding and removing e-mail input fields.
In the corresponding methods, we access the value
signal within the form model.
The signal's update()
method allows us to to add or remove items on the email
array.
Please keep in mind that changes to signal values must be done immutably.
Instead of directly manipulating the array, we always create a new array with the updated values.
This is why we use the spread operator (...
) to create a new array when adding an email and the filter()
method to create a new array when removing an email.
// ...
export class RegistrationForm {
// ...
protected addEmail(): void {
this.registrationForm.email.value.update((items) => [...items, '']);
}
protected removeEmail(removeIndex: number): void {
this.registrationForm.email.value.update((items) =>
items.filter((_, index) => index !== removeIndex)
);
}
}
Basic Form Submission
Now that we have connected our form to the template, we want to submit the form data.
Signal Forms provide two approaches for handling form submission:
Basic synchronous submission and the more powerful submit()
function for asynchronous operations.
All approaches start with a form submission event handler: In the template, we already used the (submit)
event binding on the <form>
element.
It is always necessary to prevent the default form submission behavior by calling e.preventDefault()
in our submitForm()
handler method.
Basic Synchronous Submission
For basic cases where you want to process form data synchronously, you can directly access the current form values:
// ...
export class RegistrationForm {
// ...
protected submitForm(e: Event) {
e.preventDefault();
// Access current form data
const formData = this.registrationModel();
console.log('Form submitted:', formData);
}
}
Since our data model signal is always kept in sync with the form fields, we can access the current form state at any time using this.registrationModel()
.
It is also possible to access the form data via this.registrationForm().value()
, which provides the same result.
Using the Signal Forms submit()
Function
For more complex scenarios involving asynchronous operations, loading states, and error handling, Signal Forms provide a dedicated submit()
function.
To demonstrate this, we want to simulate a registration process that involves a fake asynchronous operation.
This service method returns a Promise
that resolves after a two-second delay, simulating a network request.
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class RegistrationService {
registerUser(registrationData: Record<string, any>) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(registrationData);
}, 2000);
});
}
}
Back in the form component, we extend our submitForm()
method to use the service.
Angular's submit()
function takes care of managing the submission state, including setting the submitting
state to true
during the operation and resetting it afterward.
To handle the actual submission, it accepts a callback function where we can perform our asynchronous logic.
Once finished, we call our own resetForm()
method: It resets the data signal to the initial state and also clears form states like touched
by calling reset()
.
// ...
import { /* ... */, submit } from '@angular/forms/signals';
export class RegistrationForm {
// ...
readonly #registrationService = inject(RegistrationService);
// ...
protected async submitForm(e: Event) {
e.preventDefault();
await submit(this.registrationForm, async (form) => {
await this.#registrationService.registerUser(form().value());
console.log('Registration successful!');
this.resetForm();
});
}
protected resetForm() {
this.registrationModel.set(initialState);
this.registrationForm().reset();
}
}
Handling Submission State
To see how the submission state actually changes, we can use it in our template to provide better user feedback.
When submitting the form we can now see that the value of the submitting()
signal switches to true
as long as the form data is being submitted via our fake service.
After the asynchronous operation is complete, it switches back to false
.
<form (submit)="submitForm($event)">
<!-- ... -->
<button
type="submit"
[disabled]="registrationForm().submitting()"
[ariaBusy]="registrationForm().submitting()"
>
@if (registrationForm().submitting()) { Registering ... } @else { Register }
</button>
</form>
Basic Schema-Based Validation
One of the most powerful features of Signal Forms is schema-based validation. Instead of defining validation rules directly on form controls (as it was usual with Reactive Forms), we now create a declarative schema that describes all validation rules for our form. The interesting part is that this schema is written as code. It is not just a static configuration but can involve additional logic if needed.
In this first part of our article series, we will give you a very short introduction to schema-based validation. The next part will cover more advanced and complex scenarios – so stay tuned!
Creating a Basic Schema
Signal Forms use the schema()
function to define validation rules.
Angular comes with some very common rules by default, such as required
and minLength
.
The provided fieldPath
parameter allows us to navigate through the form structure and apply validation rules to specific fields.
import {
// ...
schema,
required,
minLength,
} from '@angular/forms/signals';
export const registrationSchema = schema<RegisterFormData>((fieldPath) => {
required(fieldPath.username, { message: 'Username is required' });
minLength(fieldPath.username, 3, {
message: 'A username must be at least 3 characters long',
});
// ...
});
Applying the Schema to our Form
To actually use the schema, we pass it as the second parameter to the form()
function:
// ...
export class RegistrationForm {
protected readonly registrationModel = signal<RegisterFormData>(initialState);
protected readonly registrationForm = form(
this.registrationModel,
registrationSchema
);
// ...
}
Now our form will automatically validate fields according to the rules defined in our schema.
It is not strictly necessary to define the schema in a separate variable. However, this approach makes the schema independent and reusable.
Built-in Validator Functions
Signal Forms provide several built-in validation functions:
Validator | Description | Example |
---|---|---|
required(field, opts) |
Field must be filled. For boolean values, checks for true |
required(fieldPath.username) |
minLength(field, length, opts) |
Minimum character count | minLength(fieldPath.username, 3) |
maxLength(field, length, opts) |
Maximum character count | maxLength(fieldPath.username, 10) |
min(field, value, opts) |
Minimum numeric value | min(fieldPath.age, 18) |
max(field, value, opts) |
Maximum numeric value | max(fieldPath.age, 120) |
email(field, opts) |
Valid email address format | email(fieldPath.email) |
pattern(field, regex, opts) |
Regular expression match | pattern(fieldPath.username, /^[a-zA-Z0-9]+$/) |
Each validator function accepts an optional opts
parameter where you can specify a custom error message.
We can use this message later to display it in the component template.
A validation schema for our registration form could look like this:
export const registrationSchema = schema<RegisterFormData>((fieldPath) => {
// Username validation
required(fieldPath.username, { message: 'Username is required' });
minLength(fieldPath.username, 3, { message: 'A username must be at least 3 characters long' });
maxLength(fieldPath.username, 12, { message: 'A username can be max. 12 characters long' });
// Age validation
min(fieldPath.age, 18, { message: 'You must be >=18 years old.' });
// Terms and conditions
required(fieldPath.agreeToTermsAndConditions, {
message: 'You must agree to the terms and conditions.',
});
});
Form-Level Validation State
Each part of the form field tree provides a valid()
signal with validation state of all field validations below this field tree branch.
Practically, this means that we can check the overall form validity by calling registrationForm().valid()
.
<!-- ... -->
@if (!registrationForm().valid()) {
<p>The form is invalid. Please correct the errors.</p>
}
<button
type="submit"
[disabled]="!registrationForm().valid() || registrationForm().submitting()"
[ariaBusy]="registrationForm().submitting()"
>
@if (registrationForm().submitting()) {
Registering ...
} @else {
Register
}
</button>
<!-- ... -->
Displaying Validation Errors
To display validation errors, we can access the errors()
signal on each field.
It returns an array of ValidationError
objects, each with a kind
property that describes the type of error, e.g., required
or minLength
.
The object can also contain a message
property with the error message defined in the schema.
These messages can be displayed directly in the template.
To make the error display reusable, we can create a dedicated component for it:
The component can receive any FieldTree
and checks for its errors when the field is already marked as touched.
It displays all errors related to the field by iterating over the errors()
signal.
To get access to the FieldState
, we have to call the field
property twice: Once to get the FieldTree
from the input signal, and a second time to get the FieldState
with its reactive properties.
import { Component, input } from '@angular/core';
import { ValidationError, WithOptionalField } from '@angular/forms/signals';
@Component({
selector: 'app-form-error',
template: `
@let state = field()();
@if (state.touched() && state.errors().length) {
<ul>
@for (error of state.errors(); track $index) {
<li>{{ error.message }}</li>
}
</ul>
}
`,
})
export class FormError<T> {
readonly field = input.required<FieldTree<T>>();
}
Now we can use this component in our form and pass any field to it.
<label>
Username
<input type="text" [control]="registrationForm.username" />
<app-form-error [field]="registrationForm.username" />
</label>
Demo
You can find a complete demo application for this blog series on GitHub and Stackblitz:
- ⚡️ Stackblitz: https://stackblitz.com/github/angular-buch/signal-forms-registration
- ⚙️ Code on GitHub: https://github.com/angular-buch/signal-forms-registration
- 💻 Live Demo: https://angular-buch.github.io/signal-forms-registration/
What's Next?
Signal Forms provide a modern and powerful way to handle forms in Angular applications.
Getting started is straightforward and simple: Create a signal, derive the form structure and connect it to the template using the Control
directive.
With schema-based validation, we can define all validation rules in a clear and reusable way.
In this first part, we've covered the fundamentals of Signal Forms:
- Setting up data models and field structures
- Connecting forms to templates
- Basic form submission
- Schema-based validation with built-in validators
- Displaying validation errors
In Part 2, we'll dive deeper into advanced validation scenarios, including custom validation functions, cross-field validation, asynchronous validation, and handling server-side errors.
In Part 3, we'll dig into modularization and customization by using child forms and building custom UI controls that integrate seamlessly with Signal Forms.
Once we published the new parts of this series they will be linked here.
Cover image: Picture from Pixabay, edited
Keywords:AngularSignalsFormsAngular 21Signal FormsSchema Validation
BackSuggestions? Feedback? Bugs? Please fork/edit this page on Github.