This project demonstrates the new Signal Forms feature introduced in Angular 21 and compares it with traditional Reactive Forms. It showcases the same form implementation using both approaches, highlighting the differences in API, syntax, and developer experience.
This demo application implements a user registration form with validation using both form paradigms:
- Signal Form: Angular 21's new signal-based forms API
- Reactive Form: Traditional reactive forms with FormBuilder
Both implementations feature:
- Email and password validation
- Nested form groups (location with name and address)
- Custom validation messages
- Form submission with loading state
- Server error emulation and handling
- Reusable input field components
- Real-time form value display
- Angular CLI 21.0.0 or higher
npm installnpm startNavigate to http://localhost:4200/.
Signal Form:
import { email, Field, form, minLength, required, submit } from '@angular/forms/signals';
@Component({
imports: [Field],
...
})
export class SignalForm {
formModel = signal({
email: '',
password: '',
location: {
name: '',
address: ''
}
});
form = form(this.formModel, (path) => {
required(path.email, { message: 'Field required' });
email(path.email, { message: 'Invalid email' });
minLength(path.password, 5, { message: 'Must contain at least 5 characters' });
});
register(event: SubmitEvent) {
event.preventDefault();
submit(this.form, async () => {
try {
await fetch('/api/update/data', {
method: 'POST',
body: JSON.stringify(this.form().value())
});
return null;
} catch {
return { kind: 'server', message: 'An error occured' }
}
});
}
}Reactive Form:
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
imports: [ReactiveFormsModule],
...
})
export class ReactiveForm {
private fb = inject(FormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(5)]],
location: this.fb.group({
name: ['', [Validators.required]],
address: ['']
})
});
async register() {
this.form.disable();
try {
await fetch('/api/update/data', {
method: 'POST',
body: JSON.stringify(this.form.value)
});
this.form.enable();
} catch {
this.form.enable();
this.form.controls.email.setErrors({ server: true });
}
}
}- Signal Forms: Validators are functions applied to paths with custom error messages inline
- Reactive Forms: Validators are composed as arrays, requiring separate error message mapping
- Signal Forms: Access state via signals (
form().value(),form().invalid()) - Reactive Forms: Direct property access (
form.value,form.invalid)
- Signal Forms: Use the
Fielddirective for automatic two-way binding - Reactive Forms: Use
ReactiveFormsModulewithFormControlandFormGroupdirectives
- Signal Forms: Built-in
submit()function with async support and automatic loading state - Reactive Forms: Manual disable/enable during async operations
- Signal Forms: Return error objects from the submit function with structured error information
- Reactive Forms: Manually set errors on form controls using
setErrors()
Signal Forms:
import { FormValueControl, ValidationError, WithOptionalField } from '@angular/forms/signals';
@Component({
selector: 'app-input-field',
templateUrl: './input-field.html',
})
export class InputField implements FormValueControl<string> {
label = input.required<string>();
// ----- FormValueControl -----
name = input('');
value = model('');
touched = model(false);
disabled = model(false);
required = input(false);
invalid = input(false);
errors = input<readonly WithOptionalField<ValidationError>[]>([]);
}Reactive Forms:
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-input-field',
templateUrl: './input-field.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => InputField),
multi: true,
},
],
})
export class InputField implements ControlValueAccessor {
label = input.required<string>();
value = model('');
touched = model(false);
disabled = model(false);
onChange = (value: string) => {};
onTouched = () => {};
// ----- ControlValueAccessor -----
writeValue(value: string) {
this.value.set(value);
}
registerOnChange(onChange: (value: string) => void) {
this.onChange = onChange;
}
registerOnTouched(onTouched: () => void) {
this.onTouched = onTouched;
}
setDisabledState?(disabled: boolean) {
this.disabled.set(disabled);
}
}- Signal Forms: Implement
FormValueControl<T>interface with signal-based inputs and models. No provider configuration needed, and automatic integration with theFielddirective. - Reactive Forms: Implement
ControlValueAccessorinterface with manualNG_VALUE_ACCESSORprovider setup, callback registration, and lifecycle methods.
src/app/
├── app.ts # Root component with navigation
├── app.html # Main layout template
├── app.routes.ts # Route configuration
├── signal-form/
│ ├── signal-form.ts # Signal form implementation
│ ├── signal-form.html # Signal form template
│ └── input-field/
│ ├── input-field.ts # Reusable input component (signal-based)
│ └── input-field.html
└── reactive-form/
├── reactive-form.ts # Reactive form implementation
├── reactive-form.html # Reactive form template
├── error-message-pipe.ts # Error message transformation
└── input-field/
├── input-field.ts # Reusable input component (reactive)
└── input-field.html
Signal Forms (signal-form.ts)
- Signal-based state management
- Path-based validator configuration
- Inline error messages
- Built-in submission handling
- Automatic form disabling during submission
- Structured server error handling via submit function return values
Reactive Forms (reactive-form.ts)
- FormBuilder API
- Validator composition
- Error message pipe for mapping
- Manual submission state management
- Nested form groups
- Server error handling via
setErrors()on form controls
- Email validation
- Password minimum length validation
- Required field validation
- Nested location object
- Form reset functionality
- Server error emulation toggle
- Real-time form value preview
- Accessible form controls