Signals in Angular streamline reactivity and bring the framework closer to developer ergonomics found in other modern UI frameworks.
The major new feature explored here is signal-based forms.
Check the official documentation:
https://next.angular.dev/essentials/signal-forms
Example Implementation:
HTML Template
<form (submit)="onRegister()">
<div>
<label for="username">Username</label>
<input
id="username"
autocomplete="username"
type="text"
fieldError
[field]="registerForm.username"
[fieldState]="registerForm.username"
/>
</div>
<div>
<label for="email">Email</label>
<input
id="email"
autocomplete="email"
type="email"
fieldError
[field]="registerForm.email"
[fieldState]="registerForm.email"
/>
</div>
<div>
<button type="submit">Submit</button>
</div>
</form>
TypeScript Component
import { ChangeDetectionStrategy, Component, model, signal } from '@angular/core'
import { email, Field, FieldPath, form, maxLength, minLength, required } from '@angular/forms/signals'
import { FieldError } from '@quezap/core/directives'
function registerFormValidator(path: FieldPath<{
email: string
username: string
}>) {
required(path.email, { message: 'Email is required.' })
email(path.email, { message: 'Email must be valid.' })
required(path.username, { message: 'Username is required.' })
minLength(path.username, 3, { message: 'Username must be at least 3 characters.' })
maxLength(path.username, 20, { message: 'Username cannot exceed 20 characters.' })
}
@Component({
selector: 'quizz-register-modal',
imports: [
Field,
FieldError,
],
templateUrl: './register-modal.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RegisterModal {
private readonly userInfo = signal({
email: '',
username: '',
})
protected readonly registerForm = form(this.userInfo, registerFormValidator)
protected onRegister() {
if (this.registerForm().invalid()) {
alert('Validation error')
return
}
alert('Registered successfully')
}
protected onCancel() {
this.visible.set(false)
}
}
Validation rules live inside registerFormValidator, written declaratively.
In addition to built-in validators, custom rules can be composed cleanly.
Example:
function registerFormValidator(path: FieldPath<{
email: string
confirmEmail: string
username: string
}>) {
required(path.email, { message: 'Email is required.' })
email(path.email, { message: 'Email must be valid.' })
email(path.confirmEmail, { message: 'Email confirmation does not match.' })
required(path.username, { message: 'Username is required.' })
minLength(path.username, 3, { message: 'Username must be at least 3 characters.' })
maxLength(path.username, 20, { message: 'Username cannot exceed 20 characters.' })
validate(path, ({ value }) => {
if (value().email !== value().confirmEmail) {
return customError({
kind: 'mismatch',
message: 'Email addresses do not match.',
})
}
return []
})
}
The form is initialized with registerForm = form(this.userInfo, registerFormValidator).
Form state properties like touched, dirty, or errors remain reactive on InputFieldState.
You can also use validateHttp or validateAsync for asynchronous checks, and directives like disabled or required accept signals for conditional enforcement.
HTTP Validation Example:
validateHttp(path.email, {
options: {
defaultValue: false,
parse: (value) => {
return Boolean(value)
},
},
request: ({ value }) => {
return value() ? `https://example.com/api/check/${value()}` : undefined
},
onSuccess(value, ctx) {
const isTaken = value
if (isTaken) {
return [customError({
kind: 'taken',
message: 'This email is already registered.',
})]
}
return []
},
onError: (error, ctx) => {
return [customError({
kind: 'server',
message: 'Server error while checking email',
})]
},
})
Zod schemas can also be integrated directly for standard validation:
protected readonly registerForm = form(this.userInfo, (path) => {
validateStandardSchema(path, zod.object({
email: zod.email('Invalid email'),
username: zod.string()
.min(3, 'At least 3 characters')
.max(20, 'At most 20 characters'),
}))
})
Submitting the Form
Use the submit function to transition the submitting signal and handle API responses cleanly:
export class RegisterModal {
private readonly message = inject(MessageService)
private readonly registerService = inject(REGISTER_SERVICE)
private readonly userInfo = signal({
email: '',
username: '',
})
protected readonly registerForm = form(this.userInfo, (path) => {
validateStandardSchema(path, zod.object({
email: zod.email('Invalid email'),
username: zod.string()
.min(3, 'At least 3 characters')
.max(20, 'At most 20 characters'),
}))
})
public readonly visible = model(false)
protected onRegister() {
if (this.registerForm().invalid()) {
return
}
submit(this.registerForm, async (form) => {
return new Promise((resolve, reject) => {
this.registerService.register(
form.email().value(),
form.username().value(),
).then(() => {
this.message.add({
severity: 'success',
summary: 'Registration successful',
detail: 'You will receive an activation email shortly.',
sticky: true,
})
resolve()
}).catch((err) => {
if (err instanceof ExternalValidationError) {
if (err.errorCode === myClient.ErrorCode.USERNAME_TAKEN) {
resolve([{
field: registrationForm.username,
error: {kind: 'server', message: 'This username is already taken'}
}])
}
}
else {
reject(err)
this.message.add({
severity: 'error',
summary: 'Registration failed',
detail: 'An error occurred while submitting the form',
life: 5000,
})
}
return err
})
})
})
}
}