Signal Forms: The Reactive Forms of the Future
Angular's Reactive Forms, built on FormControl, FormGroup, and
FormArray, have been the standard for complex form handling for years.
However, with the introduction of Signals as Angular's core reactive primitive,
the Angular team is working on a new generation of forms entirely based on Signals:
Signal Forms. This evolution promises to eliminate the RxJS dependency for form
state, drastically reduce boilerplate, and natively integrate fine-grained reactivity into
the form management system.
In this fifth article of the Modern Angular series, we will analyze the limitations of current Reactive Forms, explore the RFC proposal for Signal Forms, examine how the new primitives work, and discuss how to prepare for migration. Whether you are starting a new project or managing an existing enterprise application, this guide will provide the knowledge you need to face the future of forms in Angular.
Modern Angular Series Overview
| # | Article | Focus |
|---|---|---|
| 1 | Angular Signals | Fine-grained reactivity |
| 2 | Zoneless Change Detection | Eliminating Zone.js |
| 3 | New Template Syntax @if, @for, @defer | Modern control flow |
| 4 | Standalone Components | Architecture without NgModule |
| 5 | You are here - Signal Forms | Reactive forms with Signals |
| 6 | SSR and Incremental Hydration | Server-Side Rendering |
| 7 | Core Web Vitals in Angular | Performance and metrics |
| 8 | Angular PWA | Progressive Web App |
| 9 | Advanced Dependency Injection | Tree-shakable DI |
| 10 | Migrating from Angular 17 to 21 | Migration guide |
Limitations of Current Reactive Forms
Reactive Forms were introduced with Angular 2 and are built on a hierarchy of abstract classes
(AbstractControl, FormControl, FormGroup,
FormArray) that use RxJS Observable to notify value and status changes.
While powerful, they present several structural limitations that become apparent in complex
applications.
Structural Problems with Reactive Forms
- RxJS dependency: Every
FormControlexposesvalueChangesandstatusChangesas Observables, forcing developers to manage subscriptions, operators, and memory leaks - Weak typing: Until Angular 14,
FormGroupreturnedany. Typed Forms improved the situation, but the syntax remains verbose - AbstractControl hierarchy: The inheritance chain makes it difficult to extend or customize control behavior
- Excessive boilerplate: Creating a form with validation requires dozens of lines of TypeScript before even touching the template
- Implicit state: Properties like
dirty,touched,validare mutable and not derived, causing inconsistencies - No Signal integration: Reactive Forms don't leverage the fine-grained reactivity system of Signals, requiring manual conversion between Observable and Signal
- Complex reset: Resetting form state (values, dirty, touched) requires multiple calls and doesn't always behave as expected
// A registration form with current Reactive Forms
@Component({
selector: 'app-registration',
standalone: true,
imports: [ReactiveFormsModule],
template: `...`
})
export class RegistrationComponent implements OnInit, OnDestroy {
private fb = inject(FormBuilder);
private destroy$ = new Subject<void>();
form = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
confirmPassword: ['', Validators.required],
address: this.fb.group({
street: [''],
city: ['', Validators.required],
zip: ['', Validators.pattern(/^\d{5}$/)]
})
});
// Must manually subscribe to react to changes
ngOnInit() {
this.form.get('password')?.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe(value => {
this.form.get('confirmPassword')?.updateValueAndValidity();
});
}
// Must manually manage subscription destruction
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
get nameErrors() {
const ctrl = this.form.get('name');
if (ctrl?.hasError('required')) return 'Name is required';
if (ctrl?.hasError('minlength')) return 'Minimum 2 characters';
return '';
}
}
As you can see, even a moderately complex form requires a significant amount of boilerplate code: manual subscriptions, lifecycle management, getters for error messages, and conversions between Observables and imperative values. Signal Forms promise to solve all of these problems.
The Signal Forms Proposal: RFC and New Primitives
The Angular team has published an RFC (Request for Comments) for Signal Forms, a complete rewrite of the form management system based on Signals. The main goal is to create an API that is reactive by nature, without depending on RxJS for form state, while maintaining the power and flexibility of current Reactive Forms.
Proposed New Primitives
| Signal Forms Primitive | Reactive Forms Equivalent | Description |
|---|---|---|
FormSignal |
FormControl |
A single form value, writable signal with reactive state |
FormRecord |
FormGroup |
Group of form signals, composes multiple FormSignals into an object |
FormList |
FormArray |
Dynamic list of form signals for repeated collections |
computed() |
valueChanges |
Automatic reactive derivation from the form value |
effect() |
.subscribe() |
Automatic side-effect with cleanup without manual management |
// The same registration form with Signal Forms
@Component({
selector: 'app-registration',
standalone: true,
template: `...`
})
export class RegistrationComponent {
// Concise declaration with inline validation
name = formSignal('', {
validators: [required(), minLength(2)]
});
email = formSignal('', {
validators: [required(), email()]
});
password = formSignal('', {
validators: [required(), minLength(8)]
});
confirmPassword = formSignal('', {
validators: [required(), matchesField(this.password)]
});
address = formRecord({
street: formSignal(''),
city: formSignal('', { validators: [required()] }),
zip: formSignal('', { validators: [pattern(/^\d{5}$/)] })
});
// State is derived automatically, no subscriptions
isFormValid = computed(() =>
this.name.isValid() &&
this.email.isValid() &&
this.password.isValid() &&
this.confirmPassword.isValid() &&
this.address.isValid()
);
// No ngOnInit, no ngOnDestroy, no Subject
}
Key Advantages of Signal Forms
- Zero subscriptions: No
subscribe(), notakeUntil, no memory leaks to manage - Derived state: Properties like
isValid(),isDirty(),errors()are computed signals, always synchronized - Natively type-safe: The value type is automatically inferred from the initial value
- Template integration: Signals connect directly to the template without pipes or conversions
- Automatic cleanup: No manual component lifecycle management
Signal-Based Form State
One of the most significant changes in Signal Forms is that all form state becomes
a Signal. There are no more mutable properties like .dirty or
.touched that are updated internally: every aspect of state is a
computed signal derived from the source of truth.
const username = formSignal('', {
validators: [required(), minLength(3), maxLength(20)]
});
// Current value: WritableSignal<string>
console.log(username.value()); // ''
// Validation state: Signal<boolean>
console.log(username.isValid()); // false
console.log(username.isInvalid()); // true
// Validation errors: Signal<ValidationErrors | null>
console.log(username.errors()); // { required: true }
// Interaction state: Signal<boolean>
console.log(username.isDirty()); // false
console.log(username.isPristine()); // true
console.log(username.isTouched()); // false
console.log(username.isUntouched()); // true
// Pending state (async validation): Signal<boolean>
console.log(username.isPending()); // false
// Disabled state: Signal<boolean>
console.log(username.isDisabled()); // false
console.log(username.isEnabled()); // true
// Programmatic value update
username.value.set('angular_dev');
// Now: isValid() === true, isDirty() === true, errors() === null
// Mark as touched (e.g., on blur)
username.markAsTouched();
// Now: isTouched() === true
Comparison: State in Reactive Forms vs Signal Forms
| Property | Reactive Forms | Signal Forms |
|---|---|---|
| Value | control.value (mutable getter) |
control.value() (Signal) |
| Validity | control.valid (boolean property) |
control.isValid() (computed Signal) |
| Errors | control.errors (object or null) |
control.errors() (Signal) |
| Dirty | control.dirty (internally mutated boolean) |
control.isDirty() (computed Signal) |
| Observing changes | control.valueChanges.subscribe() |
effect(() => console.log(control.value())) |
| Cleanup | Manual (unsubscribe, takeUntil) | Automatic (bound to DestroyRef) |
The fundamental advantage is that state is always consistent. In a Reactive Form,
you might observe an intermediate state where dirty is updated but errors
is not yet. With Signal Forms, all computed values update atomically in the glitch-free
scheduling of Signals.
Validation with Signals
Signal Forms completely rethink the validation system. Validators become pure functions that
return error Signals, with native support for synchronous and asynchronous
validation without needing separate interfaces like Validator and
AsyncValidator.
// Built-in validators
const email = formSignal('', {
validators: [
required(), // Cannot be empty
email(), // Must be a valid email
maxLength(100) // Maximum 100 characters
]
});
// Custom validator: pure function
function strongPassword(): ValidatorFn {
return (value: string) => {
const hasUpperCase = /[A-Z]/.test(value);
const hasLowerCase = /[a-z]/.test(value);
const hasDigit = /\d/.test(value);
const hasSpecial = /[!@#$%^&*]/.test(value);
if (hasUpperCase && hasLowerCase && hasDigit && hasSpecial) {
return null; // Valid
}
return {
strongPassword: {
hasUpperCase,
hasLowerCase,
hasDigit,
hasSpecial,
message: 'Password must contain uppercase, lowercase, numbers and special characters'
}
};
};
}
const password = formSignal('', {
validators: [required(), minLength(8), strongPassword()]
});
// Reactive access to specific errors
const passwordErrors = computed(() => {
const errs = password.errors();
if (!errs) return [];
const messages: string[] = [];
if (errs['required']) messages.push('Password is required');
if (errs['minlength']) messages.push('Minimum 8 characters');
if (errs['strongPassword']) messages.push(errs['strongPassword'].message);
return messages;
});
// Async validator: check if username is available
function usernameAvailable(userService: UserService): AsyncValidatorFn {
return async (value: string) => {
if (value.length < 3) return null; // Too short, skip check
const exists = await userService.checkUsername(value);
return exists
? { usernameTaken: { message: `Username "${value}" is already taken` } }
: null;
};
}
@Component({
selector: 'app-signup',
standalone: true,
template: `
<label>Username</label>
<input [formSignal]="username" />
@if (username.isPending()) {
<span class="checking">Checking availability...</span>
}
@if (username.errors()?.['usernameTaken']; as err) {
<span class="error">{{ err.message }}</span>
}
`
})
export class SignupComponent {
private userService = inject(UserService);
username = formSignal('', {
validators: [required(), minLength(3)],
asyncValidators: [usernameAvailable(this.userService)],
asyncDebounceMs: 300 // Built-in debounce!
});
}
Validation Improvements in Signal Forms
- Built-in debounce: Async validators natively support
asyncDebounceMs, no need for RxJSdebounceTime() - Reactive pending state:
isPending()is a Signal that automatically updates during async validation - Cross-field validators: Can access other FormSignals as dependencies, with automatic updates when linked values change
- Composition: Validators are simple functions, easily composable and testable
Form Groups with Signals: Composition and Nesting
formRecord() allows composing multiple formSignal instances into a
hierarchical structure, equivalent to FormGroup in Reactive Forms. The key
difference is that the record's state is automatically derived from the state
of its children.
// Complete form with nested groups
const profileForm = formRecord({
personalInfo: formRecord({
firstName: formSignal('', { validators: [required()] }),
lastName: formSignal('', { validators: [required()] }),
birthDate: formSignal<Date | null>(null)
}),
contactInfo: formRecord({
email: formSignal('', { validators: [required(), email()] }),
phone: formSignal('', { validators: [pattern(/^\+?\d{10,15}$/)] })
}),
preferences: formRecord({
newsletter: formSignal(false),
theme: formSignal<'light' | 'dark'>('dark'),
language: formSignal('en')
}),
// FormList for multiple addresses
addresses: formList([
formRecord({
type: formSignal<'home' | 'work'>('home'),
street: formSignal('', { validators: [required()] }),
city: formSignal('', { validators: [required()] }),
zip: formSignal('', { validators: [required()] })
})
])
});
// State propagates automatically upward
const isProfileValid = profileForm.isValid();
// true only if ALL fields in ALL groups are valid
const isDirty = profileForm.isDirty();
// true if ANY field in any group has been modified
// Access the complete value as a typed object
const formValue = profileForm.value();
// Type inferred automatically:
// {
// personalInfo: { firstName: string; lastName: string; birthDate: Date | null }
// contactInfo: { email: string; phone: string }
// preferences: { newsletter: boolean; theme: 'light' | 'dark'; language: string }
// addresses: Array<{ type: 'home' | 'work'; street: string; city: string; zip: string }>
// }
// Add an address to the list
profileForm.controls.addresses.push(
formRecord({
type: formSignal<'home' | 'work'>('work'),
street: formSignal('', { validators: [required()] }),
city: formSignal('', { validators: [required()] }),
zip: formSignal('', { validators: [required()] })
})
);
Automatic State Propagation
Unlike Reactive Forms where group state is calculated internally and can cause temporary
inconsistencies, in Signal Forms the state of a formRecord is a
computed signal that depends on its children. This means:
formRecord.isValid()iscomputed(() => children.every(c => c.isValid()))formRecord.isDirty()iscomputed(() => children.some(c => c.isDirty()))- There are no inconsistent intermediate states thanks to glitch-free scheduling
- Resetting a record resets all children atomically
Two-Way Binding with model() and Signal Forms
Angular introduced the model() function as a two-way bindable signal
for components. Signal Forms integrate seamlessly with model(), creating
a clean bidirectional data flow between the template and form state without needing
special directives like formControlName.
// Custom input component with model()
@Component({
selector: 'app-text-input',
standalone: true,
template: `
<div class="input-wrapper" [class.invalid]="hasError()">
<label>{{ label() }}</label>
<input
[value]="value()"
(input)="onInput($event)"
(blur)="onBlur()"
[placeholder]="placeholder()"
/>
@if (hasError() && isTouched()) {
<span class="error-message">{{ errorMessage() }}</span>
}
</div>
`
})
export class TextInputComponent {
// Two-way binding with parent component
value = model<string>('');
label = input<string>('');
placeholder = input<string>('');
hasError = input<boolean>(false);
isTouched = input<boolean>(false);
errorMessage = input<string>('');
onInput(event: Event) {
const target = event.target as HTMLInputElement;
this.value.set(target.value);
}
onBlur() {
// Notify parent that the field was touched
}
}
// Usage in parent component with Signal Forms
@Component({
selector: 'app-contact-form',
standalone: true,
imports: [TextInputComponent],
template: `
<form (ngSubmit)="submit()">
<app-text-input
[(value)]="name.value"
[label]="'Full Name'"
[hasError]="name.isInvalid()"
[isTouched]="name.isTouched()"
[errorMessage]="nameError()"
/>
<app-text-input
[(value)]="email.value"
[label]="'Email'"
[hasError]="email.isInvalid()"
[isTouched]="email.isTouched()"
[errorMessage]="emailError()"
/>
<button [disabled]="!isFormValid()">Submit</button>
</form>
`
})
export class ContactFormComponent {
name = formSignal('', { validators: [required()] });
email = formSignal('', { validators: [required(), email()] });
nameError = computed(() => {
if (this.name.errors()?.['required']) return 'Name is required';
return '';
});
emailError = computed(() => {
if (this.email.errors()?.['required']) return 'Email is required';
if (this.email.errors()?.['email']) return 'Invalid email address';
return '';
});
isFormValid = computed(() =>
this.name.isValid() && this.email.isValid()
);
submit() {
console.log({ name: this.name.value(), email: this.email.value() });
}
}
Advantages of model() + Signal Forms Integration
- No special directives: No need for
[formControl]orformControlNamein the template - Reusable components: Custom inputs receive and return values through
model(), completely independent of form logic - Full type-safety: The value type flows from FormSignal through model() to the template
- Testability: Input components can be tested in isolation, without FormModule
Practical Patterns Today: Hybrid Signals + Reactive Forms Approach
While Signal Forms are still in the RFC phase, it is possible to start leveraging Signals
with current Reactive Forms by adopting a hybrid approach that facilitates
future migration. This means using toSignal() to convert form Observables
into Signals and building derived state with computed().
@Component({
selector: 'app-search',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<input [formControl]="searchControl" placeholder="Search..." />
@if (isSearching()) {
<span class="spinner">Searching...</span>
}
@if (hasResults()) {
<p>Found {{ resultCount() }} results for "{{ searchTerm() }}"</p>
}
@for (result of results(); track result.id) {
<app-result-card [result]="result" />
} @empty {
@if (searchTerm().length > 0) {
<p>No results for "{{ searchTerm() }}"</p>
}
}
`
})
export class SearchComponent {
private searchService = inject(SearchService);
searchControl = new FormControl('', { nonNullable: true });
// Convert valueChanges to Signal
searchTerm = toSignal(
this.searchControl.valueChanges.pipe(
debounceTime(300),
distinctUntilChanged()
),
{ initialValue: '' }
);
// Derived state with computed
private searchResult = toSignal(
toObservable(this.searchTerm).pipe(
switchMap(term =>
term.length > 2
? this.searchService.search(term)
: of({ items: [], total: 0 })
)
),
{ initialValue: { items: [], total: 0 } }
);
results = computed(() => this.searchResult().items);
resultCount = computed(() => this.searchResult().total);
hasResults = computed(() => this.resultCount() > 0);
isSearching = signal(false);
}
Transition Strategy: From Reactive Forms to Signal Forms
- Phase 1 (today): Convert
valueChangesto Signals withtoSignal(). Usecomputed()for derived state - Phase 2 (today): Isolate form logic in dedicated services that expose Signals
- Phase 3 (when available): Replace FormControls with FormSignals one at a time
- Phase 4: Remove
ReactiveFormsModuleand RxJS dependencies related to forms
Custom Form Controls with Signals
The ControlValueAccessor (CVA) interface is the current mechanism for creating
custom form components. With Signal Forms, this pattern will be drastically simplified.
However, it is already possible today to build cleaner CVAs by leveraging Signals.
// Current CVA improved with Signals
@Component({
selector: 'app-star-rating',
standalone: true,
template: `
<div class="star-rating" [class.disabled]="isDisabled()">
@for (star of stars(); track star) {
<button
class="star"
[class.filled]="star <= currentValue()"
[class.hovered]="star <= hoveredStar()"
(mouseenter)="hoveredStar.set(star)"
(mouseleave)="hoveredStar.set(0)"
(click)="selectRating(star)"
[disabled]="isDisabled()">
★
</button>
}
<span class="label">{{ currentValue() }} / {{ maxStars() }}</span>
</div>
`,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => StarRatingComponent),
multi: true
}
]
})
export class StarRatingComponent implements ControlValueAccessor {
maxStars = input(5);
currentValue = signal(0);
hoveredStar = signal(0);
isDisabled = signal(false);
stars = computed(() =>
Array.from({ length: this.maxStars() }, (_, i) => i + 1)
);
private onChange: (value: number) => void = () => {};
private onTouched: () => void = () => {};
writeValue(value: number): void {
this.currentValue.set(value || 0);
}
registerOnChange(fn: (value: number) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.isDisabled.set(isDisabled);
}
selectRating(star: number): void {
if (this.isDisabled()) return;
this.currentValue.set(star);
this.onChange(star);
this.onTouched();
}
}
CVA Today vs Signal Forms Future
| Aspect | Current CVA | Signal Forms (future) |
|---|---|---|
| Interface to implement | ControlValueAccessor (4 methods) |
No interface, just model() |
| Registration | NG_VALUE_ACCESSOR provider |
Automatic through [(value)] binding |
| onChange/onTouched callbacks | Manual, imperative | Automatic, reactive via Signal |
| Disabled state | setDisabledState() imperative |
isDisabled() Signal from parent |
| Boilerplate | ~30 lines of CVA code | ~5 lines with model() |
// Future: no ControlValueAccessor, no provider
@Component({
selector: 'app-star-rating',
standalone: true,
template: `
<div class="star-rating">
@for (star of stars(); track star) {
<button
class="star"
[class.filled]="star <= value()"
(click)="value.set(star)">
★
</button>
}
</div>
`
})
export class StarRatingComponent {
// model() handles everything: two-way binding, notifications, state
value = model(0);
maxStars = input(5);
stars = computed(() =>
Array.from({ length: this.maxStars() }, (_, i) => i + 1)
);
// Done. No CVA, no provider, no callbacks.
}
// In the parent:
// <app-star-rating [(value)]="rating.value" />
Planned Migration: What to Expect
Signal Forms are currently in the RFC phase and the Angular team has communicated that their introduction will be gradual and non-breaking. Reactive Forms will continue to be supported for several major releases, allowing progressive migration.
Expected Migration Timeline
| Phase | Estimated Version | What to Expect |
|---|---|---|
| RFC and feedback | Angular 19-20 | Community feedback gathering, API iteration |
| Developer Preview | Angular 20-21 | API available but subject to changes |
| Stable | Angular 21-22 | Stable API, official migration schematic |
| Recommended | Angular 22+ | Signal Forms as default approach in documentation |
| RF Deprecation | Angular 24+ | Reactive Forms marked as deprecated (with extended support) |
Preparation Strategies for Migration
- Adopt Typed Forms: If you are still using FormControl without typing, migrate to Typed Reactive Forms introduced in Angular 14 as a first step
- Use
nonNullable: Configure FormControls withnonNullable: trueto align with Signal Forms behavior - Isolate form logic: Move form creation and management into dedicated services, so migration will only involve the service, not the components
- Reduce
valueChangesusage: Where possible, usetoSignal(control.valueChanges)to start working with Signals - Prefer pure validation functions: Write validators as pure functions that return errors, not as classes with complex dependencies
- Avoid extending AbstractControl: Do not create subclasses of FormControl or FormGroup, as they will have no equivalents in Signal Forms
- Document validation rules: Maintain a clear mapping between fields and validators to facilitate conversion
// Service that isolates form logic
// Today: uses Reactive Forms internally, exposes Signals
// Tomorrow: replace implementation with Signal Forms
@Injectable({ providedIn: 'root' })
export class ProfileFormService {
private fb = inject(FormBuilder);
// Internal implementation with Reactive Forms
private _form = this.fb.nonNullable.group({
name: ['', Validators.required],
email: ['', [Validators.required, Validators.email]],
bio: ['', Validators.maxLength(500)]
});
// Public API based on Signals
readonly name = toSignal(
this._form.controls.name.valueChanges,
{ initialValue: '' }
);
readonly email = toSignal(
this._form.controls.email.valueChanges,
{ initialValue: '' }
);
readonly isValid = toSignal(
this._form.statusChanges.pipe(map(s => s === 'VALID')),
{ initialValue: false }
);
readonly isDirty = toSignal(
this._form.valueChanges.pipe(map(() => this._form.dirty)),
{ initialValue: false }
);
// Public methods that remain unchanged after migration
setValues(data: Partial<ProfileData>) {
this._form.patchValue(data);
}
getValues(): ProfileData {
return this._form.getRawValue();
}
reset() {
this._form.reset();
}
}
Best Practices for Angular Forms
Regardless of whether you are using Template-driven Forms, Reactive Forms, or preparing for Signal Forms, some best practices are universal and will help you write more robust, maintainable, and future-ready forms.
The 10 Best Practices for Angular Forms
- Choose the right form type: Template-driven for simple forms (login, contact), Reactive/Signal for complex forms (multi-step, dynamic, advanced validation)
- Isolate logic in services: Don't create FormGroup directly in the component. Use a dedicated service that exposes Signals, facilitating testing and reuse
- Declarative validation: Define validators as pure, reusable functions, not as inline logic in components
-
Show errors only after touch: Don't display error messages until the
user interacts with the field (
isTouched()orisDirty()) - Manage loading state: Disable the submit button during async validation and show a loading indicator
-
Use
nonNullable: Always configure FormControls withnonNullable: trueto avoid unexpectednullvalues after reset -
Prefer computed over effect: Derive form state (validity, errors,
completion) with
computed()rather thaneffect()orsubscribe() - Test validators in isolation: Write unit tests for each custom validator as a pure function, independent of the component
- Handle submit carefully: Prevent double submissions, handle HTTP errors, show clear feedback to the user (success/error)
-
Prepare for migration: If using Reactive Forms, gradually convert
valueChangesto Signals withtoSignal()to facilitate the future transition
When to Use Which Form Type
| Scenario | Template-Driven | Reactive Forms | Signal Forms (future) |
|---|---|---|---|
| Simple login/contact form | Recommended | Acceptable | Recommended |
| Multi-step / wizard form | Not recommended | Recommended | Recommended |
| Dynamic forms (generated fields) | Not recommended | Recommended | Recommended |
| Complex cross-field validation | Difficult | Manageable | Simple |
| Rich derived state | Limited | With RxJS | Native (computed) |
| Signal integration | Partial | With toSignal() | Native |
| Performance (change detection) | Zone.js dependent | Zone.js dependent | Fine-grained |
Anti-Patterns to Avoid in Forms
- Don't use
ngModelwith Reactive Forms: Mixing template-driven and reactive in the same form causes unpredictable behavior - Don't forget cleanup: With Reactive Forms, every
subscribe()tovalueChangesmust be managed withtakeUntilDestroyed()orDestroyRef - Don't validate only on the client: Client-side validation improves UX, but server-side validation is mandatory for security
- Don't ignore accessibility: Use
aria-describedbyto link error messages to fields, andaria-invalidto flag invalid fields - Don't use
getRawValue()without reason: If the form has no disabled fields,.valueis sufficient
Summary and Next Steps
Signal Forms represent the natural next step in Angular's evolution toward a fully signal-based framework. By eliminating the RxJS dependency for form state, reducing boilerplate, and natively integrating fine-grained reactivity, Signal Forms promise to make form management simpler, safer, and more performant.
Key Takeaways from This Article
- Reactive Forms limitations: RxJS dependency, excessive boilerplate, mutable state, rigid AbstractControl hierarchy, no Signal integration
- Signal Forms RFC: New primitives (
formSignal,formRecord,formList) based on Signals to replace FormControl, FormGroup, FormArray - Reactive state:
isValid(),isDirty(),isTouched(),errors()as always-consistent computed signals - Simplified validation: Validators as pure functions, built-in async debounce, reactive cross-field validation
- Composition with formRecord: State automatically propagated from children to parent, atomic reset, inferred typing
- model() + Signal Forms: Two-way binding without ControlValueAccessor, minimal and reusable form components
- Hybrid approach: Use
toSignal()with Reactive Forms today to prepare for migration - Simplified CVA: In the future,
model()will completely replace the ControlValueAccessor interface - Gradual migration: Reactive Forms will remain supported for a long time, migration will be assisted by official schematics
In the next article of the series, we will explore SSR and Incremental Hydration, the Server-Side Rendering techniques that allow Angular to achieve excellent loading performance, with incremental hydration that reduces Time to Interactive by progressively transferring logic from server to client.
Related deep dive: 4 - Standalone Components: Angular senza NgModules.
Related deep dive: 6 - Angular SSR e Incremental Hydration.
Frequently asked questions
What is the main goal of Signal Forms, according to the RFC proposal?
The main goal is to create an API that is reactive by nature, without depending on RxJS for form state.
How does Signal Forms handle validation compared to Reactive Forms?
Signal Forms completely rethink the validation system, with validators becoming pure functions that return error Signals, and native support for synchronous and asynchronous validation.
What is the key difference between how Form Groups are handled in Signal Forms versus Reactive Forms?
In Signal Forms, the state of a group is automatically derived from the state of its children, whereas in Reactive Forms, group state is calculated internally and can cause temporary inconsistencies.
How will the introduction of Signal Forms affect existing applications that use Reactive Forms?
The Angular team has communicated that the introduction of Signal Forms will be gradual and non-breaking, with Reactive Forms continuing to be supported for several major releases.
Commenti
Caricamento commenti...
Sign in to comment