Submit Forms the Modern Way in Angular Signal Forms
Source: Dev.to
How Signal Forms Handle Client‑Side Validation
Simple signup form built entirely with the Signal Forms API
<!-- Username field -->
<div>
Username
</div>
@if (form.username().touched() && form.username().invalid()) {
<ul>
@for (err of form.username().errors(); track $index) {
<li>- {{ err.message }}</li>
}
</ul>
}
<!-- Email field -->
<div>
Email
</div>
@if (form.email().touched() && form.email().invalid()) {
<ul>
@for (err of form.email().errors(); track $index) {
<li>- {{ err.message }}</li>
}
</ul>
}
<!-- Submit button -->
<button type="submit" [disabled]="form.invalid()">Create account</button>
Initial state – the “Create account” button is disabled because the form is invalid (no username or email entered).
Client‑side validation – when you focus a field and blur it, a validation error appears immediately:
- Username field: required → “Please enter a username”
- Email field: required → “Please enter an email address”
After entering valid values, the errors disappear and the submit button becomes enabled. So far, everything works as expected for client‑side validation.
The problem: no submission logic
When you click Create account, the browser refreshes the page. There is no async handling, no way to surface server validation errors, and no loading state.
Template snippet (no submit handler)
<form>
…
</form>
The <form> element has no (ngSubmit) or submit() handler attached, so the default browser behavior (page reload) occurs.
Field wiring
<input type="text" field="username" />
<input type="email" field="email" />
Both inputs use the field directive to connect to the Signal Form.
Conditional error rendering
@if (form.username().touched() && form.username().invalid()) {
@for (err of form.username().errors(); track $index) {
- {{ err.message }}
}
}
The same pattern is used for the email field.
Disabled submit button
<button type="submit" [disabled]="form.invalid()">Create account</button>
All of the above works perfectly for client‑side validation; we just need to add submission logic.
Component TypeScript
Model signal
protected readonly model = signal({
username: '',
email: '',
});
Signal‑based form definition
protected readonly form = form(this.model, s => {
required(s.username, { message: 'Please enter a username' });
minLength(s.username, 3, { message: 'Your username must be at least 3 characters' });
required(s.email, { message: 'Please enter an email address' });
});
All of this is client‑side validation: fast, synchronous, and runs before any submission attempt.
Mock signup service (simulates a backend call)
import { Injectable } from '@angular/core';
export interface SignupModel {
username: string;
email: string;
}
export type SignupResult =
| { status: 'ok' }
| {
status: 'error';
fieldErrors: Partial<SignupModel>;
};
@Injectable({ providedIn: 'root' })
export class SignupService {
async signup(value: SignupModel): Promise<SignupResult> {
await new Promise(r => setTimeout(r, 700));
const fieldErrors: Partial<SignupModel> = {};
// Username rules
if (value.username.trim().toLowerCase() === 'brian') {
fieldErrors.username = 'That username is already taken.';
}
// Email rules
if (value.email.trim().toLowerCase() === 'brian@test.com') {
fieldErrors.email = 'That email is already taken.';
}
return Object.keys(fieldErrors).length
? { status: 'error', fieldErrors }
: { status: 'ok' };
}
}
The service returns either a successful result or an object containing field‑specific server errors.
- Client validation – checks shape/format (required, email format, min length).
- Server validation – enforces business rules (reserved usernames, blocked domains, uniqueness).
Implementing Proper Form Submission
Signal Forms provides a new submit() API that handles a lot of the hard stuff for us.
1. Inject the signup service
import { inject } from '@angular/core';
import { SignupService } from './signup.service';
export class SignupComponent {
// …
private readonly signupService = inject(SignupService);
}
2. Add an onSubmit method
protected onSubmit(event: Event) {
// Prevent the default browser form submission (page reload)
event.preventDefault();
// Use the new submit() API
this.form.submit(async () => {
// Mark all fields as touched so validation messages appear if needed
this.form.markAllAsTouched();
// If the form is still invalid after client‑side checks, abort
if (this.form.invalid()) return;
// Call the mock service
const result = await this.signupService.signup(this.model());
// Handle server‑side errors
if (result.status === 'error') {
// Map each field error onto the corresponding Signal Form field
for (const [field, message] of Object.entries(result.fieldErrors)) {
// `field` is a key of SignupModel (e.g., 'username' or 'email')
// Cast to any to satisfy the index signature
(this.form as any)[field].setServerError(message);
}
return;
}
// Success path – you could navigate, show a toast, etc.
console.log('Signup successful!');
});
}
3. Wire the method to the template
<form (ngSubmit)="onSubmit($event)">
…
<button type="submit" [disabled]="form.submitting()">
{{ form.submitting() ? 'Creating…' : 'Create account' }}
</button>
</form>
form.submitting()is a built‑in loading‑state signal provided bysubmit().- The button text automatically switches to a loading state while the async operation runs.
What the submit() API Gives You
| Feature | How it works |
|---|---|
| Async handling | Accepts an async callback; the form stays in a “submitting” state until the promise resolves. |
| Loading state | form.submitting() is a signal you can bind to UI (e.g., disabling the button or showing a spinner). |
| Touched‑field handling | markAllAsTouched() ensures validation messages appear for untouched fields when the user tries to submit. |
| Server‑error mapping | setServerError(message) on a field signal adds a server‑side error that integrates with the existing error UI. |
| Prevent default | The API automatically calls event.preventDefault() when used via (ngSubmit). |
Full Component Example
import { Component, signal, inject } from '@angular/core';
import { form, required, minLength } from '@angular/forms';
import { SignupService, SignupModel } from './signup.service';
@Component({
selector: 'app-signup',
templateUrl: './signup.component.html',
standalone: true,
})
export class SignupComponent {
// Model signal
protected readonly model = signal({ username: '', email: '' });
// Signal‑based form
protected readonly form = form(this.model, s => {
required(s.username, { message: 'Please enter a username' });
minLength(s.username, 3, { message: 'Your username must be at least 3 characters' });
required(s.email, { message: 'Please enter an email address' });
});
// Service injection
private readonly signupService = inject(SignupService);
// Submit handler
protected onSubmit(event: Event) {
this.form.submit(async () => {
this.form.markAllAsTouched();
if (this.form.invalid()) return;
const result = await this.signupService.signup(this.model());
if (result.status === 'error') {
for (const [field, message] of Object.entries(result.fieldErrors)) {
(this.form as any)[field].setServerError(message);
}
return;
}
console.log('Signup successful!');
});
}
}
Recap
- Client‑side validation works out‑of‑the‑box with Signal Forms.
- Submission requires the new
submit()API to prevent page reloads, manage loading state, and handle server‑side errors. - Implementation steps – inject a service, add an
onSubmitmethod that callsform.submit(...), mark fields as touched, abort on client‑side errors, call the backend, map server errors, and handle success.
With these pieces in place, your Angular Signal Forms will be fully reactive, user‑friendly, and ready for real‑world server interactions. Happy coding!
Prevent the Browser from Performing a Full‑Page Refresh
protected onSubmit(event: Event) {
event.preventDefault();
}
Use the New submit() Function
import { ..., submit } from '@angular/forms/signals';
protected onSubmit(event: Event) {
…
submit(this.form);
}
Async Callback (Second Argument)
protected onSubmit(event: Event) {
…
submit(this.form, async f => {
// …
});
}
What Angular does for you
- Calls the callback only if the form is valid.
- Automatically marks all fields as touched.
- Tracks submission state for you.
Inside the callback you receive the form’s field tree via f.
Access the Current Form Value
submit(this.form, async f => {
const value = f().value(); // validated client‑side value
});
Call the Signup Service
submit(this.form, async f => {
…
const result = await this.signupService.signup(value);
});
This simulates a backend call.
Handle Server‑Side Validation Errors
If the server rejects the submission, return validation errors instead of throwing or manually setting state.
submit(this.form, async f => {
…
if (result.status === 'error') {
// …
}
});
Create an Errors Array
import { ..., ValidationError } from '@angular/forms/signals';
submit(this.form, async f => {
…
if (result.status === 'error') {
const errors: ValidationError.WithOptionalField[] = [];
}
});
ValidationError.WithOptionalField represents a validation error that can optionally target a specific field.
Add Errors for Specific Fields
submit(this.form, async f => {
…
if (result.status === 'error') {
if (result.fieldErrors.username) {
errors.push({
field: f.username,
kind: 'server',
message: result.fieldErrors.username,
});
}
}
});
Email Errors (same pattern)
submit(this.form, async f => {
…
if (result.status === 'error') {
if (result.fieldErrors.email) {
errors.push({
field: f.email,
kind: 'server',
message: result.fieldErrors.email,
});
}
}
});
Return Errors (or undefined)
submit(this.form, async f => {
…
if (result.status === 'error') {
…
return errors.length ? errors : undefined;
}
});
- Returning errors tells Angular: “Do not submit the form; surface these errors.”
- Returning
undefinedtells Angular: “Everything’s good – the form submitted successfully.”
Wire the onSubmit Method in the Template
<form (ngSubmit)="onSubmit($event)">
…
</form>
Disable the Submit Button While Submitting
<button type="submit" [disabled]="form.submitting()">
{{ form.submitting() ? 'Creating…' : 'Create account' }}
</button>
Test the Complete Flow
- Client‑side validation works when fields are blurred.
- Enter values that pass client validation but fail server validation.
- Client errors disappear (the form is technically valid).
- The button becomes enabled; click it.
- The button disables and shows “Creating…” while the mock server responds.
- Server validation errors appear in the same UI as client errors.
- No console log appears because we returned errors, so the form did not submit.
- Fix the username and email, then submit again.
- The form now submits successfully and the data is logged/processed.
Complete Component Code
import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
Field,
form,
minLength,
required,
submit,
ValidationError
} from '@angular/forms/signals';
import { SignupModel, SignupService } from './signup.service';
@Component({
selector: 'app-form',
imports: [CommonModule, Field],
templateUrl: './form.component.html',
styleUrl: './form.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormComponent {
protected readonly model = signal({
// … (model properties)
});
// … (rest of the component: form definition, onSubmit implementation, etc.)
}
The snippet above reflects the full component structure; fill in the omitted parts (model fields, form definition, onSubmit logic) with the code shown in the previous sections.
Component (TypeScript)
username: '',
email: '',
});
protected readonly form = form(this.model, s => {
required(s.username, { message: 'Please enter a username' });
minLength(s.username, 3, { message: 'Your username must be at least 3 characters' });
required(s.email, { message: 'Please enter an email address' });
});
private readonly signupService = inject(SignupService);
protected onSubmit(event: Event) {
event.preventDefault();
submit(this.form, async f => {
const value = f().value();
const result = await this.signupService.signup(value);
if (result.status === 'error') {
const errors: ValidationError.WithOptionalField[] = [];
if (result.fieldErrors.username) {
errors.push({
field: f.username,
kind: 'server',
message: result.fieldErrors.username,
});
}
if (result.fieldErrors.email) {
errors.push({
field: f.email,
kind: 'server',
message: result.fieldErrors.email,
});
}
return errors.length ? errors : undefined;
}
console.log('Submitted:', value);
return undefined;
});
}
Template (HTML)