Overview and Cautionary Note
Before diving in, it's essential to understand that the APIs discussed here are still in an experimental phase and could undergo significant modifications. For any project with an imminent production launch, I would advise against adopting these APIs right now.
Unsurprisingly, the Angular Team is actively working to integrate signals into the framework's existing APIs wherever feasible. Forms are no exception, and we're witnessing the emergence of a novel form category: Signal forms. This development expands the Angular form ecosystem to include three distinct types:
- React Form: where the component class takes charge of form logic and state
- Template Driven Form: where the HTML template manages the form's behavior
- Signal Form: where signals serve as the controlling mechanism
The Philosophy Behind Signal Forms
Irrespective of the technology stack, a form ultimately represents a collection of UI elements designed to capture structured user input, typically accompanied by validation rules that guarantee data quality.
Signal forms break down this idea into four fundamental components:
- Data model: represents the current state of the data held by the form
- Field State: encompasses metadata related to a specific field, including its value, validity, and control status
- Field logic: involves the field's business rules, such as validation criteria and conditional visibility
- UI Control: serves as the interactive bridge between native HTML elements, custom components, and the end-user
A particularly notable aspect of Signal forms is their approach to data management. The form library itself doesn't hold onto the data. Instead, as developers, we supply a signal that represents our data model, and that signal becomes the authoritative reference point for all fields within the form.
Here's a code snippet to clarify this concept :)
interface Assigned {
name: string;
firstname: string;
}
interface Todo {
title: string;
description: string;
status: TodoStatus;
assigned: Assigned[];
}
@Component({
selector: 'app-form',
templateUrl: './app-form.html'
})
export class AppForm {
todoModel = signal<Todo>({
title: '',
description: '',
status: 'not_begin',
assigned: []
}); // We create the model that will be the source of truth for the form and it's tree field
todoForm = form(this.todoModel); // we create the form which is linked to the model
}
Having this model as the single source of truth brings two significant implications:
- When you change the model (using the set or update functions), the form field will automatically reflect that change.
- When a user interacts with a field, the resulting modification will be written back to the model.
A Hierarchical Structure of Fields
Utilizing the form function grants you entry to a hierarchy of fields. The form itself is considered a Field, referred to as the Root Field.
Each Field instance exposes its own state, which provides access to its value, validity status, and other properties. You can obtain this state by invoking the Field function.
This can be better understood with a practical example :)
interface Assigned {
name: string;
firstname: string;
}
interface Todo {
title: string;
description: string;
status: TodoStatus;
assigned: Assigned[]
}
@Component({
selector: 'app-form',
templateUrl: './app-form.html'
})
export class AppForm {
todoModel = signal<Todo>({
title: '',
description: '',
status: 'not_begin',
assigned: []
}); // We create the model that will be the source of truth for the form and it's tree field
todoForm = form(this.todoModel); // we create the form which is linked to the model
titleField: Field<string> = this.todoForm.title;
firstAssigned: Field<Assigned> = this.todoForm.assigned[0];
firstAssignedName: Field<string> = firstAssigned.name;
}
Breaking Down the Field Instance
As mentioned before, calling a Field instance gives you its state. This state is built from six key components.
- value: A WritableSignal designed for reading and updating the field's content.
- errors: A signal that provides access to any validation errors on the field.
- valid: A signal used to determine if the field passes validation.
- disabled: A signal to check whether the field is in a disabled state.
- touched: A signal indicating whether the user has engaged with the field or any of its nested fields.
- dirty: A signal showing if the field or any of its children has been modified.
For an in-depth look at all that a Field instance provides, or to view the actual implementation, check out this resource.
Let's demonstrate this with some example code.
interface Assigned {
name: string;
firstname: string;
}
interface Todo {
title: string;
description: string;
status: TodoStatus;
assigned: Assigned[]
}
@Component({
selector: 'app-form',
template: `<button [disabled]="titleField().valid()">Submit</button>`
})
export class AppForm {
todoModel = signal<Todo>({
title: '',
description: '',
status: 'not_begin',
assigned: []
}); // We create the model that will be the source of truth for the form and it's tree field
todoForm = form(this.todoModel); // we create the form which is linked to the model
titleField: Field<string> = this.todoForm.title;
firstAssigned: Field<Assigned> = this.todoForm.assigned[0];
firstAssignedName: Field<string> = firstAssigned.name;
}
Connecting Fields to the User Interface
We've covered the essentials so far: defining the data model, establishing the form, traversing the field tree, and accessing field instances.
The next logical step is to link a UI control, such as an input, textarea, or custom element, to the field so that users can effectively interact with it.
Angular's Signal Form comes with a pre-built directive for this purpose. The source code for it can be found at this link.
This directive is tasked with several key roles:
- Two-way data flow: it ensures the field's value stays synchronized whether changes come from the user or are set programmatically.
- Applied logic: it links the field's business logic, such as validation and read-only status.
- Event propagation: it relays other control events, including dirty and touched states.
- Integration: on a deeper level, it injects the NgControl token to leverage existing functionalities and ensure smooth interoperability.
This design showcases the clear goal of creating a robust link between the HTML layer and Angular's powerful form capabilities.
Let's illustrate this with a code example :)
interface Assigned {
name: string;
firstname: string;
}
interface Todo {
title: string;
description: string;
status: TodoStatus;
assigned: Assigned[]
}
@Component({
selector: 'app-form',
templateUrl: './app-form.html',
imports: [Control]
})
export class AppForm {
todoModel = signal<Todo>({
title: '',
description: '',
status: 'not_begin',
assigned: []
}); // We create the model that will be the source of truth for the form and it's tree field
todoForm = form(this.todoModel); // we create the form which is linked to the model
}
<form novalidate>
<input type="text" [control]="todoForm.title" />
<input type="text" [control]="todoForm.description" />
<select [control]="todoForm.status">
<option [ngValue]="not_begin">Not Begined</option>
<option [ngValue]="in_progress">In Progress</option>
<option [ngValue]="finished">Finished</option>
</select>
</form>
Closing Thoughts
This concludes the initial installment of a three-part series. The objective here was to establish a solid groundwork for understanding Signal forms.
We've broken down the core elements of how Signal forms function, gaining a clear understanding of their architecture and purpose.
At this stage, our form doesn't include any validation. Recall that validation serves as the business logic for our field, which is precisely the topic we will delve into in the next article.
The important takeaway from this piece is the developer's full authority over their exposed data model. This model is what ultimately bridges the gap between the Field and its corresponding value.
The form function facilitates the creation of a field tree, which is navigable using a simple dot notation syntax.
Finally, a Field instance, through a specific function call, lets you fetch its own state—allowing you to, for instance, check what its current value is.
