OpenLang Generative UI - Fenrir
Every question our business asks is legit; too bad we never build the dashboards they want...
· 13 min read
For years, Angular developers have been choosing between two approaches for handling forms: Template-driven forms (simple but limited) or Reactive Forms (powerful but verbose). While Reactive Forms became the go-to for complex applications, they always felt like they required a bit too much boilerplate. But with Angular 21, there's a new kid on the block: Signal Forms.
I've been playing around with Signal Forms lately, building a small demo app to really understand how they work. And I have to say, I'm impressed. This new API brings together the best of both worlds: the reactivity and power of Reactive Forms with the simplicity and elegance that Angular Signals provide.
Let's take a quick trip down memory lane. Angular has always had two ways to handle forms:
Template-driven forms are the simpler approach. You use directives like ngModel in your templates, and Angular handles most of the wiring behind the scenes. They're great for simple forms but quickly become unwieldy as complexity grows. Testing them is also a pain because so much logic lives in the template.
Reactive Forms gave us more control. You define your form structure in TypeScript using FormGroup, FormControl, and FormArray. This approach is powerful and testable, but it comes with a lot of boilerplate. Every time I create a reactive form, I find myself writing the same patterns over and over: creating controls, setting validators, subscribing to value changes, manually handling state...
Both approaches have served us well, but they were built before Signals revolutionized Angular's reactivity model.
Angular Signals, introduced in Angular 16 and becoming the recommended approach in more recent versions, fundamentally changed how we think about reactive state in Angular. Instead of relying on RxJS observables for everything or manually triggering change detection, signals provide a more intuitive and performant way to handle reactive data.
Here's what makes signals special:
() and updating with .set() or .update() just feels naturalSo when the Angular team announced Signal Forms in Angular 21, it made perfect sense. Why not bring this elegant reactivity model to forms?
Let me show you what you need to get started. First, make sure you're on Angular 21 or higher:
{
"dependencies": {
"@angular/common": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/forms": "^21.0.0"
}
}
The Signal Forms API lives in @angular/forms/signals, which is separate from the traditional forms APIs. Here's what you'll import:
import { form, Field, required, validate, validateHttp, debounce } from '@angular/forms/signals';
import { signal } from '@angular/core';
That's it! You're ready to build signal-based forms.
Let me walk you through the core concepts using a practical example. I built a simple person form that demonstrates all the key features of Signal Forms.
The first step is defining your form's data model. In Signal Forms, your model is just a regular signal:
interface Person {
name: string;
age: number;
}
// Step 1: Create a form model with signal()
personModel = signal<Person>({
name: '',
age: 0,
});
This is beautifully simple. Your form data lives in a signal, which means it's reactive from the start. No need to create FormControl instances or wire up value accessors manually.
Here's where Signal Forms really shine. You pass your model signal to the form() function, and it gives you back a FieldTree with all the reactive form state you need:
// Step 2: Pass the form model to form() to create a FieldTree
personForm = form(this.personModel, (schemaPath) => {
// Add validation rules
required(schemaPath.name, { message: 'Name is required' });
required(schemaPath.age, { message: 'Age is required' });
validate(schemaPath.age, ({ value }) => {
if (value() < 0) {
return {
kind: 'age-error',
message: 'Age must be non-negative',
};
}
if (value() < 18 || value() > 65) {
return null; // Valid
}
return {
kind: 'age-error',
message: 'Age must be between lower than 18 or higher than 65',
};
});
});
Let me break down what's happening here:
form() function takes your model signal and a configuration functionschemaPath that mirrors your model's structurerequired() and validate()null (valid) or an error objectNotice how type-safe this is. The schemaPath knows about name and age because TypeScript infers it from your Person interface. Try to validate a field that doesn't exist, and you'll get a compile error. This is exactly the kind of developer experience I love.
Binding your form to the template is incredibly simple with the [field] directive:
<input
id="name-input"
type="text"
[field]="personForm.name"
/>
That's it. One directive, and Angular handles all the two-way binding, validation, and state tracking for you. Compare this to reactive forms where you need formControlName and have to set up a FormGroup in the template. Signal Forms eliminate all that ceremony.
Let's look at how Signal Forms work in your templates. The integration is clean and modern, taking advantage of Angular's latest features.
One thing I really appreciate about the Signal Forms approach is how easy it makes proper accessibility. Here's how I added ARIA attributes to my form:
<input
id="name-input"
type="text"
[field]="personForm.name"
[attr.aria-invalid]="
personForm.name().touched() ? (personForm.name().invalid() ? 'true' : 'false') : null
"
[attr.aria-valid]="
personForm.name().touched() && personForm.name().valid() ? 'true' : null
"
[attr.aria-describedby]="
personForm.name().touched() && personForm.name().invalid() ? 'name-errors' : null
"
/>
The form field exposes signals for every piece of state you need:
personForm.name().touched() - Has the user interacted with this field?personForm.name().invalid() - Does the field have validation errors?personForm.name().valid() - Is the field valid?These signals make it trivial to set up proper ARIA attributes for screen readers. The reactivity is automatic—when the field state changes, the ARIA attributes update immediately.
Angular's new control flow syntax (@if, @for) pairs perfectly with Signal Forms:
@if (personForm.name().touched() && personForm.name().invalid()) {
<ul id="name-errors" class="error-list" role="alert" aria-live="polite">
@for (error of personForm.name().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
}
This reads almost like plain English:
The errors() signal returns an array of error objects, each with the message we defined in our validators. No more checking hasError() for specific validator names or digging through error objects manually.
Sometimes you want to show all form errors in one place, like at the top of the form. Signal Forms makes this easy too! You can iterate through all fields and their errors:
<form (submit)="onSubmit($event)" class="person-form">
@if (personForm().touched() && personForm().invalid()) {
<div class="form-errors" role="alert" aria-live="polite">
<h3>Please fix the following errors:</h3>
<ul>
@for (field of [personForm.name(), personForm.age()]; track field) {
@if (field.touched() && field.invalid()) {
@for (error of field.errors(); track error) {
<li>{{ error.message }}</li>
}
}
}
</ul>
</div>
}
<!-- Rest of form fields -->
</form>
Or, if you prefer a more dynamic approach that doesn't require manually listing fields, you can create a helper method in your component:
getAllFormErrors() {
const errors: string[] = [];
// Iterate through all fields
const fields = [
{ name: 'Name', field: this.personForm.name() },
{ name: 'Age', field: this.personForm.age() }
];
for (const { name, field } of fields) {
if (field.touched() && field.invalid()) {
for (const error of field.errors()) {
errors.push(`${name}: ${error.message}`);
}
}
}
return errors;
}
Then in your template:
@if (personForm().invalid() && personForm().touched()) {
<div class="form-summary-errors" role="alert">
<h3>Form Errors:</h3>
<ul>
@for (error of getAllFormErrors(); track error) {
<li>{{ error }}</li>
}
</ul>
</div>
}
This is particularly useful for longer forms where you want to provide a summary of all errors at the top, making it easier for users to see everything that needs fixing at a glance.
For development and debugging, you can easily display the current form state:
<div class="form-info">
<h2>Form State</h2>
<div class="info-grid">
<div><strong>Name:</strong> {{ personForm.name().value() }}</div>
<div><strong>Age:</strong> {{ personForm.age().value() }}</div>
<div><strong>Name Valid:</strong> {{ personForm.name().valid() }}</div>
<div><strong>Age Valid:</strong> {{ personForm.age().valid() }}</div>
<div><strong>Name Touched:</strong> {{ personForm.name().touched() }}</div>
<div><strong>Age Touched:</strong> {{ personForm.age().touched() }}</div>
<div><strong>Name Dirty:</strong> {{ personForm.name().dirty() }}</div>
<div><strong>Age Dirty:</strong> {{ personForm.age().dirty() }}</div>
<div><strong>Name Pending:</strong> {{ personForm.name().pending() }}</div>
</div>
</div>
Every field in your form tree has signals for:
value() - The current field valuevalid() / invalid() - Validation statetouched() - Has the user focused/blurred this field?dirty() - Has the user changed the value?pending() - Is async validation currently running?errors() - Array of validation errorsThis makes debugging forms a breeze. You can instantly see exactly what state your form is in.
Now let's dive into some of the more advanced capabilities of Signal Forms.
The validate() function gives you complete control over validation logic. Here's the age validator from my demo:
validate(schemaPath.age, ({ value }) => {
if (value() < 0) {
return {
kind: 'age-error',
message: 'Age must be non-negative',
};
}
if (value() < 18 || value() > 65) {
return null; // Valid
}
return {
kind: 'age-error',
message: 'Age must be between lower than 18 or higher than 65',
};
});
This validator has some interesting logic: the age must be either under 18 or over 65 (yes, it's a bit contrived for demo purposes, but it shows the flexibility).
The key things to note:
value() to read itnull for valid valueskind and message for invalid valueskind field lets you categorize errors if neededYou can write any validation logic you want—async validators, cross-field validation, whatever your application needs.
One of the most powerful features of Signal Forms is built-in support for asynchronous validation. This is perfect for scenarios where you need to validate against a backend API, like checking if a username is available.
Signal Forms provides validateHttp for HTTP-based async validation:
import { form, Field, required, validateHttp } from '@angular/forms/signals';
userModel = signal({
username: '',
email: ''
});
userForm = form(this.userModel, (schemaPath) => {
required(schemaPath.username, { message: 'Username is required' });
// Async validation with HTTP request
validateHttp(schemaPath.username, {
request: ({value}) => {
const usernameValue = value();
// Skip HTTP validation if username is empty (required validator handles this)
if (!usernameValue || usernameValue.trim() === '') {
return undefined;
}
return `/api/check-username?username=${encodeURIComponent(usernameValue)}`;
},
onSuccess: (response: any) => {
if (response.taken) {
return [
{
kind: 'usernameTaken',
message: 'Username is already taken',
}
];
}
return []; // Username is available - return empty array for valid
},
onError: (error: unknown) => [
{
kind: 'networkError',
message: 'Could not verify username availability',
}
],
});
});
Let me break down what's happening:
undefined to skip validation. You can use the field's value() signal to build dynamic URLs[] if valid, or an array of error objects if invalidShowing Pending State
While async validation runs, the field's pending() signal returns true. Use this to show loading indicators and provide feedback to users:
<div class="form-group">
<label for="username-input">
Username:
<input
id="username-input"
type="text"
[field]="userForm.username"
[attr.aria-invalid]="
userForm.username().touched() && !userForm.username().pending()
? (userForm.username().invalid() ? 'true' : 'false')
: null
"
[attr.aria-valid]="
userForm.username().touched() && userForm.username().valid() && !userForm.username().pending() ? 'true' : null
"
[attr.aria-busy]="userForm.username().pending() ? 'true' : null"
[attr.aria-label]="
userForm.username().pending()
? 'Username (validating)'
: 'Username'
"
[attr.aria-describedby]="
userForm.username().touched()
? (userForm.username().pending()
? 'username-pending'
: userForm.username().invalid()
? 'username-errors'
: null)
: null
"
/>
</label>
@if (userForm.username().touched()) {
@if (userForm.username().pending()) {
<div id="username-pending" class="pending-message" role="status" aria-live="polite" aria-label="Validation in progress">
<span class="spinner" aria-hidden="true"></span>
Checking availability...
</div>
}
@if (userForm.username().invalid() && !userForm.username().pending()) {
<ul id="username-errors" class="error-list" role="alert" aria-live="polite">
@for (error of userForm.username().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
}
}
@if (userForm.username().valid() && userForm.username().touched() && !userForm.username().pending()) {
<span class="success">✓ Username is available</span>
}
</div>
The pending() signal makes it easy to show different UI states:
aria-busy="true" and appropriate ARIA labelsaria-invalid and aria-describedby attributesThis creates a smooth user experience where users get immediate feedback as the async validation runs. The comprehensive ARIA attributes ensure screen readers are properly informed about the validation state, making the form accessible to all users.
Debouncing Async Validation
For a better user experience, you might want to debounce async validation so it doesn't fire on every keystroke. Use the debounce function as a separate call before validateHttp:
import { form, Field, required, validateHttp, debounce } from '@angular/forms/signals';
userForm = form(this.userModel, (schemaPath) => {
required(schemaPath.username, { message: 'Username is required' });
// Debounce the username field to delay validation until user stops typing
debounce(schemaPath.username, 500); // Wait 500ms after user stops typing
// Async validation with HTTP request
validateHttp(schemaPath.username, {
request: ({value}) => {
const usernameValue = value();
if (!usernameValue || usernameValue.trim() === '') {
return undefined;
}
return `/api/check-username?username=${encodeURIComponent(usernameValue)}`;
},
onSuccess: (response: any) => {
if (response.taken) {
return [
{
kind: 'usernameTaken',
message: 'Username is already taken',
}
];
}
return []; // Username is available
},
onError: (error: unknown) => [
{
kind: 'networkError',
message: 'Could not verify username availability',
}
],
});
});
The debounce function prevents excessive API calls while the user is still typing, making your application more efficient and user-friendly.
Signal Forms track multiple types of state for each field:
You can also check the state of the entire form:
<button type="submit" [disabled]="personForm().invalid()">
Submit
</button>
Notice personForm() with parentheses—the form itself is a signal! This is brilliant because it means the button's disabled state automatically updates whenever any field's validation state changes.
Here's something that makes Signal Forms especially powerful: they work seamlessly with OnPush change detection.
@Component({
selector: 'app-root',
imports: [RouterOutlet, Field],
templateUrl: './app.html',
styleUrl: './app.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class App {
// ... form code ...
}
With traditional reactive forms and OnPush, you often need to manually trigger change detection or use RxJS observables with the async pipe. Signal Forms "just work" because signals automatically notify Angular when they change. This means better performance with zero extra effort.
Understanding how data flows through Signal Forms helps you use them effectively. Here's the complete picture:
Initialization Flow:
personModel (signal) → form() → personForm (FieldTree with signals)
User Input Flow:
User types → [field] directive → field signal updates → personModel signal updates → UI reflects changes
Validation Flow:
Field value changes → validators run → valid/invalid signals update → template updates
Form Submission:
onSubmit(event: Event) {
event.preventDefault();
const person = this.personModel();
console.log('Submitted person:', person);
// e.g., send person to an API or service
}
The beautiful part is that you always have direct access to your model through the signal. No need to call form.value or deal with partial values—just read the signal and you have your complete, typed data.
Let me show you what the same form would look like with Reactive Forms:
Reactive Forms Version:
// Component
personForm = new FormGroup({
name: new FormControl('', [Validators.required]),
age: new FormControl(0, [Validators.required, this.ageValidator])
});
ageValidator(control: AbstractControl): ValidationErrors | null {
const value = control.value;
if (value < 0) {
return { ageError: 'Age must be non-negative' };
}
if (value >= 18 && value <= 65) {
return { ageError: 'Age must be lower than 18 or higher than 65' };
}
return null;
}
onSubmit() {
const person: Person = this.personForm.value;
console.log('Submitted person:', person);
}
<!-- Template -->
<form [formGroup]="personForm" (submit)="onSubmit()">
<input formControlName="name" />
<div *ngIf="personForm.get('name')?.invalid && personForm.get('name')?.touched">
<div *ngFor="let error of personForm.get('name')?.errors | keyvalue">
{{ error.value }}
</div>
</div>
<!-- More template code... -->
</form>
Signal Forms Version:
// Component
personModel = signal<Person>({ name: '', age: 0 });
personForm = form(this.personModel, (schemaPath) => {
required(schemaPath.name, { message: 'Name is required' });
required(schemaPath.age, { message: 'Age is required' });
validate(schemaPath.age, ({ value }) => {
if (value() < 0) {
return { kind: 'age-error', message: 'Age must be non-negative' };
}
if (value() < 18 || value() > 65) return null;
return { kind: 'age-error', message: 'Age must be lower than 18 or higher than 65' };
});
});
onSubmit(event: Event) {
event.preventDefault();
const person = this.personModel();
console.log('Submitted person:', person);
}
<!-- Template -->
<form (submit)="onSubmit($event)">
<input [field]="personForm.name" />
@if (personForm.name().touched() && personForm.name().invalid()) {
<ul>
@for (error of personForm.name().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
}
<!-- More template code... -->
</form>
The differences are striking:
FormGroup, FormControl, or Validators imports[field] instead of [formGroup] + formControlNameAfter working with Signal Forms for a while, here's my honest assessment:
Benefits:
FormControl instances or setting up FormGroup hierarchies. Just define your model and validators.Considerations:
That said, for new projects or new forms in existing projects, I'd definitely reach for Signal Forms first.
Here are some tips I've learned while working with Signal Forms:
1. Structure Your Models
Keep your form models separate from your domain models if they have different shapes:
// Domain model from API
interface User {
id: string;
firstName: string;
lastName: string;
email: string;
}
// Form model
interface UserForm {
firstName: string;
lastName: string;
email: string;
}
userFormModel = signal<UserForm>({
firstName: '',
lastName: '',
email: ''
});
2. Centralize Validators
For validators you'll reuse across multiple forms, create a validators utility file:
// validators.ts
export const emailValidator = ({ value }) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value())
? null
: { kind: 'email', message: 'Please enter a valid email address' };
};
// In your component
validate(schemaPath.email, emailValidator);
3. Display Errors Consistently
Create a reusable error component for consistent error display:
// field-errors.component.ts
@Component({
selector: 'app-field-errors',
template: `
@if (field().touched() && field().invalid()) {
<ul class="error-list" role="alert" aria-live="polite">
@for (error of field().errors(); track error) {
<li>{{ error.message }}</li>
}
</ul>
}
`
})
export class FieldErrorsComponent {
field = input.required<FieldSignal>();
}
4. Handle Form Submission
Always prevent default form submission and check validity:
onSubmit(event: Event) {
event.preventDefault();
if (this.personForm().invalid()) {
// Mark all fields as touched to show errors
// (This is a feature that should be added to the API)
return;
}
const data = this.personModel();
// Submit data to API
}
Signal Forms represent a significant step forward for Angular forms. They embrace modern Angular patterns—signals, standalone components, the new control flow syntax—and deliver a developer experience that's both more intuitive and more powerful than what came before.
I've been working with Angular for years now, and forms have always been one of those areas where I felt the framework could do better. Template-driven forms were too magical, and Reactive Forms were too verbose. Signal Forms hit a sweet spot: they're explicit enough to be testable and maintainable, but concise enough that you don't drown in boilerplate.
The type safety is what really seals the deal for me. Having TypeScript catch form-related errors at compile time instead of runtime is huge. And the way signals integrate with OnPush change detection means you get better performance without having to think about it.
If you're starting a new Angular 21 project, I'd say Signal Forms should be your default choice. For existing applications, evaluate on a case-by-case basis—new features might be a good opportunity to try Signal Forms, while existing forms might not be worth rewriting immediately.
The Angular team continues to iterate on this API, so I expect it to mature quickly as more developers adopt it and provide feedback. I'm excited to see where this goes!
Want to try it yourself? Check out my demo project on GitHub: SignalFormsDemo (or wherever you've hosted it).
And as always, feel free to reach out if you have questions or want to chat about Angular!
Last updated: 2026-01-28