We are already past the release of the newest Angular version – 21 – if you haven’t had a chance to familiarize yourself with all the changes that were implemented in this iteration, I encourage you to check out our article.
Here we will focus only and exclusively on signal forms, which by the way were probably the most anticipated feature of recent months (years?). In this article you will learn how signal forms differ from Reactive Forms, what the new validation and reactivity model looks like, how to create custom controls without ControlValueAccessor boilerplate, and how to gradually migrate existing forms thanks to compatForm.
Signal forms compared to their predecessors are a breath of fresh air. Familiar concepts like validators, dirty state, valid or invalid remain, but their implementation has been completely rewritten using signals. However, not everything is identical to predecessors – we no longer have concepts like form group, form control or form array. On the other hand, typed forms are now actually typed.
[Master revolutionary Signal Forms! Write code without takeUntil(), leverage automatic type inference for forms, and build incredibly performant applications. Workshop date: 20.01.2026]
Introduction to Signal Forms
Signal forms themselves are already a novelty, working based on signals that we already know quite well. Nevertheless, they introduce new nomenclature and features to the world of forms that we have no chance of knowing based on previous years with Reactive and Template Driven Forms. One of them is form model.
Form Model
Form model is a writable signal with which we initialize our form. This is crucial because form model directly corresponds to the type of our form. Forms are now very well typed and directly infer the type from the initializing object.
Another very important point is that any modifications and updates to our form model will be directly propagated and reflected by the form. Currently this initialization signal is the owner of the state that the form represents – they remain in full synchronization.
export class LoginComponent {
// Form model
loginModel = signal({
email: '',
password: ''
})
// We init form with defined form model
loginForm = form(this.loginModel)
}
This is a fundamental change compared to reactive forms. In the previous approach, the form managed its own state independently – we mapped object fields to form controls, but the form state existed independently from the source object. Any synchronization of the form with an external model required manual value updates.
// We map entity properties into form controls, since that point they are not synchronized
form = fb.group({
email: [entity.email],
password: [entity.password]
});
Initializing the Form
Creating a new form is done through the form() function. This is already characteristic for Angular that more and more functionality is implemented in a functional way. The first argument of the function is our previously mentioned form model, which will give type to our form and based on it the Form Tree will be initialized – a hierarchical structure of fields where each object in the model becomes a node with its own children, and each primitive value becomes a terminal field (leaf). Thanks to this, navigation through the form naturally corresponds to navigation through data.
import { form } from '@angular/forms/signals';
loginForm = form(this.loginModel);
// Navigation through dot - like a regular object
loginForm.email // email field
loginForm.password // password field
Typing – End of Compromises
Typed Reactive Forms introduced in Angular 14 were a step in the right direction. However, in practice their typing has limitations that can frustrate on a daily basis. Signal forms, designed from the ground up with TypeScript in mind, solve these problems.
Problem 1: Nullable Everywhere
In Reactive Forms each FormControl by default has type T | null:
const emailControl = new FormControl('');
// Type: FormControl<string | null>
emailControl.value; // string | null - always nullable!
We can use nonNullable, but it requires explicit declaration for each control:
const emailControl = new FormControl('', { nonNullable: true });
// Only now the type is FormControl<string>
Signal forms – type comes directly from the model:
const model = signal({ email: '' });
const myForm = form(model);
myForm.email().value(); // string - no null!
Problem 2: get() Method Loses Types
This is one of the most irritating aspects of typed Reactive Forms:
const form = new FormGroup({
user: new FormGroup({
email: new FormControl(''),
name: new FormControl('')
})
});
// Even though form is typed...
const email = form.get('user.email');
// ...email is of type: AbstractControl<unknown, unknown> | null
// We have to cast manually
const emailTyped = form.get('user.email') as FormControl<string | null>;
The get() method takes a string – TypeScript is not able to verify if the path is correct.
Signal forms – full navigation typing:
const model = signal({
user: { email: '', name: '' }
});
const myForm = form(model);
// Full typing at every level
myForm.user.email().value(); // string
// Typo? Compilation error!
myForm.user.emial; // ❌ Property 'emial' does not exist
Problem 3: FormArray Loses Structure
FormArray in typed forms can be problematic:
const users = new FormArray([
new FormGroup({
name: new FormControl(''),
email: new FormControl('')
})
]);
// When accessing through at()...
users.at(0); // AbstractControl - we lose FormGroup structure information!
users.at(0).get('name'); // again AbstractControl | null
Signal forms preserve full structure:
interface User {
name: string;
email: string;
}
const model = signal<{ users: User[] }>({
users: [{ name: 'Jan', email: 'jan@example.com' }]
});
const myForm = form(model);
// Full typing preserved!
myForm.users[0].name().value(); // string
myForm.users[0].email().value(); // string
// Iteration is also typed
for (const [index, userField] of myForm.users) {
userField.name().value(); // TypeScript knows it's a string
}
Problem 4: Dynamic Forms
Adding controls at runtime is a typing nightmare:
const form = new FormGroup({
name: new FormControl('')
});
form.addControl('email', new FormControl(''));
// TypeScript still thinks form only has 'name'
form.controls.email; // ❌ Property 'email' does not exist
Signal forms – model is the source of truth:
const model = signal<{ name: string; email?: string }>({
name: ''
});
const myForm = form(model);
// Adding a field = updating the model
model.update(m => ({ ...m, email: 'new@example.com' }));
// Typing automatically accounts for optional field
if (myForm.email) {
myForm.email().value(); // string
}
How Does It Work Under the Hood?
The heart of the type system is FieldTree<TModel> – a type that recursively maps model structure to form structure:
- For objects – each property becomes a form field
- For arrays – elements accessible by index with preserved type
- For primitives – terminal field without children
Thanks to this, TypeScript always knows what type each field has – no manual assertions, no casting, no guessing.
Summary
| Aspect | Typed Reactive Forms | Signal Forms |
| Nullable by default | Yes (T | null) | No – depends on model |
| Navigation (get() / dot) | Loses types | Full typing |
| Arrays | at() returns AbstractControl | Preserves structure |
| Dynamic fields | Require type assertion | Model as source of truth |
| Refactoring | Partially safe | Fully safe |
Typed Reactive Forms were a compromise – typing was added to existing API. Signal forms were designed from scratch with TypeScript as a priority. The difference is noticeable from the first line of code.
Validation
Similar to Reactive Forms, we have access to predefined validators. However, the way of applying them is completely different – instead of passing validators when creating a control, we call functions pointing to the field and validator.
import { form, required, minLength, email, pattern } from '@angular/forms/signals';
const loginForm = form(this.loginModel, (login) => {
required(login.email);
email(login.email);
required(login.password);
minLength(login.password, 8);
});
// Reactive Forms - validator at field
new FormControl('', [Validators.required, Validators.email])
// Signal Forms - validators in separate section
form(model, (f) => {
required(f.email);
email(f.email);
});
Everyone will have to subjectively assess how this affects form readability. For small forms the difference is cosmetic. For large ones – centralization can be an advantage.
Predefined Validators
Signal forms provide a set of built-in validators:
required(path); // required field
min(path, minValue); // minimum numeric value
max(path, maxValue); // maximum numeric value
minLength(path, length); // minimum length
maxLength(path, length); // maximum length
pattern(path, regex); // regex pattern
email(path); // email format
Custom Validators
Creating custom validators is simpler than ever:
import { form, validate, customError } from '@angular/forms/signals';
const registrationForm = form(this.model, (f) => {
// Custom validator - function receives context with value
validate(f.username, ({ value }) => {
const username = value();
if (username.includes(' ')) {
return customError({ kind: 'no-spaces', message: 'Name cannot contain spaces' });
}
return undefined; // no error
});
// Validator with access to other fields
validate(f.confirmPassword, ({ value, valueOf }) => {
if (value() !== valueOf(f.password)) {
return customError({ kind: 'password-mismatch', message: 'Passwords are not identical' });
}
return undefined;
});
});
Validator context (ctx) gives access to:
- value() – current field value
- valueOf(path) – value of any other field
- state – full field state (touched, dirty, etc.)
- stateOf(path) – state of any other field
Validator Reactivity – Automatic Dependency Tracking
Here lies one of the biggest advantages of signal forms. Validators work inside a reactive context, which means Angular automatically tracks all read signals.
Let’s look at a password comparison validator:
validate(f.confirmPassword, ({ value, valueOf }) => {
if (value() !== valueOf(f.password)) {
return customError({ kind: 'password-mismatch' });
}
return undefined;
});
This validator will run when:
- confirmPassword changes (because we call value())
- password changes (because we call valueOf(f.password))
So the validator reacts to changes of every read signal, not just the field it’s assigned to.
Why Is This Revolutionary?
Think about the classic „passwords must match” scenario:
- User enters password in password → confirmPassword validator runs → error (confirm is empty)
- User enters the same in confirmPassword → validator runs → OK
- User goes back and changes password → confirmPassword validator automatically runs → error (they no longer match)
In Reactive Forms point 3 required manual work:
// Reactive Forms - need to manually link
this.form.get('password').valueChanges.subscribe(() => {
this.form.get('confirmPassword').updateValueAndValidity();
});
In signal forms this happens automatically. Zero subscriptions, zero manual updateValueAndValidity() calls.
Performance Consideration
Since the validator reacts to all read signals, it’s worth reading only what’s really needed:
// ⚠️ Reads entire form - will run on EVERY change
validate(f.someField, ({ stateOf }) => {
const everything = stateOf(f).value(); // entire form!
// ...
});
// ✅ Precise dependencies - will run only when one of two fields changes
validate(f.someField, ({ value, valueOf }) => {
const mine = value();
const related = valueOf(f.otherField);
// ...
});
Asynchronous Validation
For validation requiring server requests we have validateAsync and validateHttp:
import { validateHttp } from '@angular/forms/signals';
const form = form(this.model, (f) => {
validateHttp(f.username, {
request: ({ value }) =>
value() ? `/api/check-username?name=${value()}` : undefined,
onSuccess: (result) =>
result.taken ? customError({ kind: 'taken', message: 'Name taken' }) : undefined,
onError: () =>
customError({ kind: 'server-error', message: 'Error checking availability' })
});
});
Asynchronous validation runs only when synchronous validation passes successfully.
Conditional Functions
Analogously to validators, we have functions allowing dynamic control of field state:
import { form, disabled, hidden, readonly } from '@angular/forms/signals';
const orderForm = form(this.model, (order) => {
// Field disabled conditionally
disabled(order.discountCode, ({ valueOf }) =>
valueOf(order.orderType) === 'wholesale'
);
// Field hidden conditionally
hidden(order.companyName, ({ valueOf }) =>
valueOf(order.customerType) !== 'business'
);
// Read-only field
readonly(order.totalPrice);
});
Key difference from Reactive Forms: these functions are reactive. Changing orderType will automatically enable/disable the discountCode field – without manual subscribing and calling enable()/disable().
What Does disabled/hidden/readonly Mean?
Fields in these states are skipped when determining parent state:
- Hidden field with an error doesn’t make the form invalid
- Disabled field marked as dirty doesn’t affect parent’s dirty
- Readonly field doesn’t participate in validation
Reusability – Schema
Schemas are a complete novelty. Schema allows defining a set of rules once and applying them in multiple places:
import { schema, required, email, minLength } from '@angular/forms/signals';
// Define once
const addressSchema = schema<Address>((addr) => {
required(addr.street);
required(addr.city);
required(addr.zipCode);
pattern(addr.zipCode, /^\d{2}-\d{3}$/);
});
const contactSchema = schema<Contact>((contact) => {
required(contact.email);
email(contact.email);
minLength(contact.phone, 9);
});
Applying Schemas
import { form, apply, applyEach } from '@angular/forms/signals';
// Apply to nested object
const customerForm = form(this.customerModel, (customer) => {
required(customer.name);
apply(customer.billingAddress, addressSchema);
apply(customer.shippingAddress, addressSchema);
apply(customer.contact, contactSchema);
});
// Apply to each array element
const orderForm = form(this.orderModel, (order) => {
applyEach(order.addresses, addressSchema);
});
[Master revolutionary Signal Forms! Write code without takeUntil(), leverage automatic type inference for forms, and build incredibly performant applications. Workshop date: 20.01.2026]
Conditional Schemas
We can apply schemas conditionally:
import { applyWhen, applyWhenValue } from '@angular/forms/signals';
const form = form(this.model, (f) => {
// Schema applied when condition is met
applyWhen(f.payment,
({ valueOf }) => valueOf(f.paymentMethod) === 'card',
cardPaymentSchema
);
// Schema applied based on field value (with type narrowing!)
applyWhenValue(f.document,
(doc): doc is Invoice => doc.type === 'invoice',
invoiceSchema
);
});
Schemas are a powerful tool for organizing application architecture. You define rules for Address once – you’re sure that every form with an address validates it identically.
Field Directive – One Way for Everything
In Reactive Forms we had to remember about different directives:
<!-- Reactive Forms - different directives -->
<input [formControl]="emailControl">
<input formControlName="email">
<div formGroupName="address">...</div>
<div formArrayName="items">...</div>
Signal forms simplify this to one [field] directive:
<!-- Signal Forms - always [field] -->
<input [field]="myForm.email">
<input [field]="myForm.address.street">
<input [field]="myForm.items[0].name">
Typing in Template
The Field directive is strictly typed. When you try to bind a number type field to an input expecting string:
<!-- myForm.age is FieldTree<number> -->
<input type="text" [field]="myForm.age">
<!-- ❌ Type 'FieldTree<number>' is not assignable to type 'FieldTree<string>' -->
This is something unseen before in Angular forms – type errors detected in template!
Automatic State Binding
The Field directive automatically synchronizes state between field and UI control:
// Control can declare these inputs - Field will automatically fill them
@Component({...})
export class MyInput {
value = model<string>(''); // value - required
disabled = input<boolean>(false); // is disabled
touched = input<boolean>(false); // is touched
errors = input<ValidationError[]>([]); // validation errors
required = input<boolean>(false); // is required
// ... and more
}
<my-input [field]="myForm.email"></my-input>
<!-- All states synchronized automatically -->
FormValueControl Contract
To create a custom control compatible with [field], you just need to implement a simple contract:
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'my-custom-input',
template: `...`
})
export class MyCustomInput implements FormValueControl<string> {
// Only required field
readonly value = model<string>('');
// Optional - Field will automatically bind if they exist
readonly disabled = input<boolean>(false);
readonly errors = input<ValidationError[]>([]);
readonly touched = input<boolean>(false);
}
Migration – compatForm
If you have an existing application with Reactive Forms, you probably won’t rewrite everything at once (and rightly so). Fortunately, Angular anticipated this scenario and provides compatForm() – a function allowing to mix both worlds.
import { compatForm } from '@angular/forms/signals';
import { FormControl, Validators } from '@angular/forms';
// Existing FormControl with validators
const ageControl = new FormControl(5, Validators.min(3));
// Model mixing signal forms with Reactive Forms
const model = signal({
name: 'Jan', // regular signal forms field
age: ageControl // existing FormControl
});
const myForm = compatForm(model);
How Does It Work?
compatForm automatically „unwraps” values from FormControl:
myForm.age().value(); // 5 (number, not FormControl!)
myForm.name().value(); // 'Jan'
// If you need access to the original FormControl:
myForm.age().control(); // FormControl<number>
Bidirectional Synchronization
State is synchronized both ways:
// Change through FormControl
ageControl.setValue(10);
myForm.age().value(); // 10
// Change through signal forms
myForm.age().value.set(15);
ageControl.value; // 15
// Touched/dirty also propagates
ageControl.markAsTouched();
myForm.age().touched(); // true
myForm().touched(); // true (propagation to parent)
Validators Are Respected
const control = new FormControl(1, Validators.min(5));
const model = signal({ age: control });
const myForm = compatForm(model);
myForm.age().valid(); // false
myForm().valid(); // false (propagation)
control.setValue(10);
myForm.age().valid(); // true
Limitation: No Rules on FormControl Fields
You cannot apply signal forms rules (like required(), validate()) directly to fields that are FormControl – TypeScript will block it:
compatForm(model, (f) => {
required(f.name); // ✅ OK - regular field
required(f.age); // ❌ Compilation error - age is FormControl
// But you can read FormControl values in validators of other fields:
validate(f.name, ({ valueOf }) => {
return valueOf(f.age) < 18
? customError({ kind: 'too-young' })
: undefined;
});
});
This makes sense – FormControl validation should stay with that FormControl. Mixing two validation systems on one field is asking for trouble.
Submit and Reset
Submitting the Form
Signal forms provide a submit() function that handles typical submission flow:
import { submit } from '@angular/forms/signals';
async function onSubmit() {
await submit(myForm, async (form) => {
// 1. At this point all fields are already marked as touched
// 2. If form is invalid - this function will NOT be called
// 3. form().submitting() === true during execution
const response = await api.save(form().value());
// We can return server errors
if (response.error) {
return [{
field: myForm.email,
error: customError({ kind: 'server', message: response.error })
}];
}
return undefined; // success
});
}
What submit() does under the hood:
- Marks all fields as touched (to show errors)
- Checks valid() – if false, aborts and doesn’t call action
- Sets submitting to true
- Calls the action
- Applies any server errors to appropriate fields
- Sets submitting to false
Submitting State
You can use submitting() to block UI:
<button [disabled]="myForm().submitting()">
{{ myForm().submitting() ? 'Sending...' : 'Submit' }}
</button>
submitting propagates down – if parent is submitting, children are too:
myForm().submitting(); // true
myForm.email().submitting(); // true
Resetting the Form
The reset() method clears interaction state (touched, dirty):
myForm.email().reset(); // resets single field
myForm().reset(); // resets entire form and all children
Optionally you can pass a new value:
myForm().reset({ email: '', password: '' });
Note: reset() doesn’t change value if you don’t pass it – it only resets UI state.
Debouncing
For fields where we don’t want to react to every keystroke (e.g. search, async validation), we have debounce():
import { form, debounce } from '@angular/forms/signals';
const searchForm = form(this.model, (f) => {
// Update model only 300ms after last change
debounce(f.query, 300);
});
You can also pass your own debounce function:
debounce(f.query, (ctx, abortSignal) => {
return new Promise(resolve => {
const timeout = setTimeout(resolve, 500);
abortSignal.addEventListener('abort', () => clearTimeout(timeout));
});
});
Debouncing is inherited – if you set it on parent, children will also be debounced (unless they override with their own).
Custom Controls – End of ControlValueAccessor
In Reactive Forms creating a custom form control required implementing ControlValueAccessor – an interface with four methods, magical provider with forwardRef, and manual calling of onChange/onTouched. Every Angular developer knows this boilerplate:
// Reactive Forms - ControlValueAccessor 😵
@Component({
selector: 'my-input',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MyInputComponent),
multi: true
}
]
})
export class MyInputComponent implements ControlValueAccessor {
private onChange: (value: string) => void = () => {};
private onTouched: () => void = () => {};
writeValue(value: string): void { /* ... */ }
registerOnChange(fn: (value: string) => void): void { this.onChange = fn; }
registerOnTouched(fn: () => void): void { this.onTouched = fn; }
setDisabledState(isDisabled: boolean): void { /* ... */ }
}
Signal Forms reduce this to one line.
FormValueControl – Minimalistic Contract
To create a control compatible with [field] directive, you just need to implement FormValueControl<T> interface:
import { Component, model } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'my-input',
template: `
<input
[value]="value()"
(input)="value.set($event.target.value)"
/>
`
})
export class MyInputComponent implements FormValueControl<string> {
readonly value = model('');
}
That’s all. One model() signal and the control is ready to use:
<my-input [field]="myForm.email"></my-input>
The [field] directive automatically synchronizes value between form and control. Change in form → value() update. Change in control → form model update.
Optional Inputs – Automatic State Binding
FormValueControl defines a number of optional inputs. If you declare them, [field] directive will automatically fill them:
@Component({
selector: 'my-input',
template: `
<div class="input-wrapper" [class.has-error]="invalid()">
<input
[value]="value()"
[disabled]="disabled()"
[attr.name]="name()"
(input)="value.set($event.target.value)"
(blur)="touched.set(true)"
/>
@if (invalid() && touched()) {
<div class="errors">
@for (error of errors(); track error.kind) {
<span>{{ error.message }}</span>
}
</div>
}
</div>
`
})
export class MyInputComponent implements FormValueControl<string> {
// Required
readonly value = model('');
// Optional - Field will automatically bind if they exist
readonly disabled = input(false);
readonly touched = model(false); // model() allows two-way binding
readonly errors = input<ValidationError[]>([]);
readonly invalid = input(false);
readonly name = input('');
readonly required = input(false);
readonly readonly = input(false);
}
Full list of optional inputs:
- disabled – whether field is disabled
- readonly – whether field is read-only
- touched – whether user interacted with field (can be model() for two-way binding)
- dirty – whether value was changed
- invalid – whether validation failed
- pending – whether async validation is in progress
- errors – list of validation errors
- name – field name in form
- required – whether field is required
- min, max, minLength, maxLength, pattern – values from validators
You declare only those you need. The rest is ignored.
FormCheckboxControl – For Checkboxes
For checkbox-type controls there is a separate FormCheckboxControl contract:
import { Component, model } from '@angular/core';
import { FormCheckboxControl } from '@angular/forms/signals';
@Component({
selector: 'my-checkbox',
template: `
<label>
<input
type="checkbox"
[checked]="checked()"
(change)="checked.set($event.target.checked)"
/>
<ng-content></ng-content>
</label>
`
})
export class MyCheckboxComponent implements FormCheckboxControl {
readonly checked = model(false);
}
Usage:
<my-checkbox [field]="myForm.agreeToTerms">
I accept the terms
</my-checkbox>
Controls as Directives
Control doesn’t have to be a component – it can be a directive on a native element:
@Directive({
selector: 'input[myCustomInput]',
host: {
'[value]': 'value()',
'(input)': 'value.set($event.target.value)',
'(blur)': 'onBlur()'
}
})
export class MyCustomInputDirective implements FormValueControl<string> {
readonly value = model('');
readonly touched = model(false);
onBlur() {
this.touched.set(true);
}
}
<input myCustomInput [field]="myForm.email" />
The [field] directive will automatically detect FormValueControl and connect it to the form.
Signal Forms eliminate ceremony. Instead of implementing an interface with four methods and configuring providers, you declare one signal and the control works.
Before You Start – A Few Notes
Status: Experimental
Signal forms are marked as @experimental 21.0.0. What does this mean in practice?
- API may change in future versions (though the core will likely remain stable)
- Edge cases and bugs may appear
- Documentation is still in development
Does this mean they’re not worth using? In my opinion – they are worth it, especially in new projects. But in critical production applications consider whether you’re ready for potential API migrations.
Import
Signal forms live in a separate entry point:
import { form, required, validate, ... } from '@angular/forms/signals';
Don’t mix with imports from @angular/forms (unless using compatForm).
[Master revolutionary Signal Forms! Write code without takeUntil(), leverage automatic type inference for forms, and build incredibly performant applications. Workshop date: 20.01.2026]
Summary
Signal forms are not an evolution of Reactive Forms – they are a rethought from scratch implementation of forms in Angular. Key changes:
- Model as source of truth – form and data are always synchronized
- Real typing – TypeScript knows everything, without compromises
- Reactivity out of the box – validators react to dependency changes without manual binding
- One API – [field] directive instead of a zoo of directives
- Schemas – reusable validation rules
- Simple Controls – FormValueControl instead of ControlValueAccessor
Should you migrate existing applications? If you have time and budget – yes. If not – compatForm allows introducing signal forms gradually, form by form.
And new projects? There’s no dilemma here. Signal forms are the future of forms in Angular.
