Signal Forms

Build forms in Angular using Signal Forms.

In this guide, we will take a look at building forms with Signal Forms. We'll cover building forms with the HlmField component, how to handle validation and how to display errors.

Demo

We are going to build the following form. It has a simple text input and a textarea. On submit, we'll validate the form data and display any errors.

Bug Report

Help us improve by reporting bugs you encounter.

0/100 characters Include steps to reproduce, expected behavior, and what actually happened.

Approach

This form leverages Angular Signal Form for flexible form handling. We'll build our form using the HlmField component, which gives you complete flexibility over the markup and styling.

  • Uses Angular signal() to represent the form's data structure and form() for the form state management and validation.
  • [formField]="form.email" binds the form field to the input element.
  • HlmField components for building accessible forms.
  • Use submit() or FormRoot for form submission and revealing validation errors.

Anatomy

Here's a basic example of a form using the HlmField component and binding it via [formField]="form.title" to the input element.

Notice that HlmFieldLabel does not need a for attribute. When a label and a control are placed inside the same HlmField , the for attribute is automatically set to the control's id .

<hlm-field>
	<label hlmFieldLabel>Bug Title</label>
	<input
		hlmInput
		id="title"
		placeholder="Login button not working on mobile"
		autocomplete="off"
		[formField]="form.title"
	/>
	@for (error of form.title().errors(); track error) {
		<hlm-field-error [validator]="error.kind">{{ error.message }}</hlm-field-error>
	}
</hlm-field>

Form

Create a form model

We'll start by creating the form model with signal() to represent the form's data structure, then wrap it with form() to enable binding to input elements and configure validations.

bug-report-form.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, maxLength, minLength, required } from '@angular/forms/signals';

@Component({
  selector: 'spartan-bug-report-form',
  imports: [],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `...`,
})
export class BugFormsDemo {
  protected readonly _model = signal({
    title: '',
    description: '',
  });

  public readonly form = form(this._model, (schemaPath) => {
    required(schemaPath.title, { message: 'Title must be entered.' });
    minLength(schemaPath.title, 5, { message: 'Title must be at least 5 characters.' });
    maxLength(schemaPath.title, 100, { message: 'Title cannot exceed 32 characters.' });

    required(schemaPath.description, { message: 'Description must be entered.' });
    minLength(schemaPath.description, 20, {
      message: 'Description must be at least 20 characters.',
    });
    maxLength(schemaPath.description, 100, {
      message: 'Description must be at most 100 characters',
    });
  });
}

Setup the form

Angular recommends using FormRoot as the most common way to handle form submission. It automatically takes care of three things:

  • Sets novalidate on the form element to disable native browser validation.
  • Prevents the default submit event, stopping browser navigation on form submission.
  • Calls submit() to mark all interactive fields as touched, run validation, and reveal any errors.
bug-report-form.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, FormRoot, maxLength, minLength, required } from '@angular/forms/signals';

@Component({
  selector: 'spartan-bug-report-form',
  imports: [FormRoot],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!-- formRoot sets novalidate and handles form submission -->
    <form [formRoot]="form">
      <!-- Build the form here -->
    </form>
  `,
})
export class BugFormsDemo {
  protected readonly _model = signal({
    title: '',
    description: '',
  });

  public readonly form = form(
    this._model,
    (schemaPath) => {
      required(schemaPath.title, { message: 'Title must be entered.' });
      minLength(schemaPath.title, 5, { message: 'Title must be at least 5 characters.' });
      maxLength(schemaPath.title, 100, { message: 'Title cannot exceed 32 characters.' });

      required(schemaPath.description, { message: 'Description must be entered.' });
      minLength(schemaPath.description, 20, {
        message: 'Description must be at least 20 characters.',
      });
      maxLength(schemaPath.description, 100, {
        message: 'Description must be at most 100 characters',
      });
    },
    {
      // triggers the submission flow by calling `submit()` - marks all fields as touched, revealing validation errors
      submission: {
        action: async () => {
          // submit to api
          const model = this._model();
        },
      },
    },
  );
}

Alternatively, you can call submit() directly for more control over when and how submission is triggered. This is useful for multi-step wizards, auto-save, or triggering submission from outside the form element. When doing so:

  • You must manually set novalidate on the form element.
  • You must prevent the default browser navigation event yourself.
bug-report-form.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, maxLength, minLength, required, submit } from '@angular/forms/signals';

@Component({
  selector: 'spartan-bug-report-form',
  imports: [],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <!-- novalidate - disables browser built-in validation -->
    <form novalidate (submit)="submitBug($event)">
      <!-- Build the form here -->
    </form>
  `,
})
export class BugFormsDemo {
  protected readonly _model = signal({
    title: '',
    description: '',
  });

  public readonly form = form(this._model, (schemaPath) => {
    required(schemaPath.title, { message: 'Title must be entered.' });
    minLength(schemaPath.title, 5, { message: 'Title must be at least 5 characters.' });
    maxLength(schemaPath.title, 32, { message: 'Title cannot exceed 32 characters.' });

    required(schemaPath.description, { message: 'Description must be entered.' });
    minLength(schemaPath.description, 20, {
      message: 'Description must be at least 20 characters.',
    });
    maxLength(schemaPath.description, 100, {
      message: 'Description must be at most 100 characters',
    });
  });

  async submitBug(event: Event) {
    // stop browser from navigating on form submission
    event.preventDefault();

    // marks all fields as touched, revealing validation errors
    const success = await submit(this.form, async () => {
      // submit to api
      const model = this._model();
    });
  }
}

Build the form

We can now build the form using HlmField component and bind the form controls using [formField] .

bug-report-form.ts
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
import { form, FormField, FormRoot, maxLength, minLength, required } from '@angular/forms/signals';
import { HlmButtonImports } from '@spartan-ng/helm/button';
import { HlmCardImports } from '@spartan-ng/helm/card';
import { HlmFieldImports } from '@spartan-ng/helm/field';
import { HlmInputImports } from '@spartan-ng/helm/input';
import { HlmInputGroupImports } from '@spartan-ng/helm/input-group';

@Component({
	selector: 'spartan-bug-report-form',
	imports: [
		FormRoot,
		FormField,
		HlmCardImports,
		HlmFieldImports,
		HlmButtonImports,
		HlmInputImports,
		HlmInputGroupImports,
	],
	changeDetection: ChangeDetectionStrategy.OnPush,
	host: { class: 'w-full sm:max-w-md' },
	template: `
		<hlm-card>
			<hlm-card-header>
				<h3 hlmCardTitle>Bug Report</h3>
				<p hlmCardDescription>Help us improve by reporting bugs you encounter.</p>
			</hlm-card-header>
			<div hlmCardContent>
				<!-- formRoot sets novalidate and handles form submission -->
				<form [formRoot]="form" id="form-bug-report">
					<hlm-field-group>
						<hlm-field>
							<label hlmFieldLabel for="title">Bug Title</label>
							<input
								id="title"
								hlmInput
								placeholder="Login button not working on mobile"
								autoComplete="off"
								[formField]="form.title"
							/>

							@for (error of form.title().errors(); track error) {
								<hlm-field-error [validator]="error.kind">
									{{ error.message }}
								</hlm-field-error>
							}
						</hlm-field>
						<hlm-field>
							<label hlmFieldLabel for="description">Description</label>
							<hlm-input-group>
								<textarea
									hlmInputGroupTextarea
									id="description"
									class="min-h-24"
									placeholder="I'm having an issue with the login button on mobile."
									rows="6"
									[formField]="form.description"
								></textarea>
								<hlm-input-group-addon align="block-end">
									<span hlmInputGroupText>{{ descriptionLength() }}/100 characters</span>
								</hlm-input-group-addon>
							</hlm-input-group>
							<hlm-field-description>
								Include steps to reproduce, expected behavior, and what actually happened.
							</hlm-field-description>

							@for (error of form.description().errors(); track error) {
								<hlm-field-error [validator]="error.kind">
									{{ error.message }}
								</hlm-field-error>
							}
						</hlm-field>
					</hlm-field-group>
				</form>
			</div>
			<hlm-card-footer>
				<hlm-field orientation="horizontal">
					<button hlmBtn variant="outline" type="button" (click)="reset()">Reset</button>
					<button hlmBtn type="submit" form="form-bug-report">Submit</button>
				</hlm-field>
			</hlm-card-footer>
		</hlm-card>
	`,
})
export class BugFormsDemo {
	protected readonly _model = signal({
		title: '',
		description: '',
	});

	public readonly form = form(
		this._model,
		(schemaPath) => {
			required(schemaPath.title, { message: 'Title must be entered.' });
			minLength(schemaPath.title, 5, { message: 'Title must be at least 5 characters.' });
			maxLength(schemaPath.title, 32, { message: 'Title cannot exceed 32 characters.' });

			required(schemaPath.description, { message: 'Description must be entered.' });
			minLength(schemaPath.description, 20, {
				message: 'Description must be at least 20 characters.',
			});
			maxLength(schemaPath.description, 100, {
				message: 'Description must be at most 100 characters',
			});
		},
		{
			// triggers the submission flow by calling `submit()` - marks all fields as touched, revealing validation errors
			submission: {
				action: async () => {
					const model = this._model();

					console.log('You submitted the following values:', JSON.stringify(model, null, 2));

					// submit to api
				},
			},
		},
	);

	public readonly descriptionLength = computed(() => this.form.description().value().length);

	reset() {
		this.form().reset({
			title: '',
			description: '',
		});
	}
}

Done

That's it. You now have a fully accessible form with client-side validation.

Calling submit() checks if the form is valid and performs the action. If the form is invalid, it marks all fields as touched and reveals their validation errors.

Validation

Signal Forms provides a powerful validation system . Validator functions support either synchronous or asynchronous validation. You can use built-in validators or create custom ones. Built-in validators are available such as required() or email() . Each validator carries its error message directly alongside the validation logic, keeping them co-located and easy to maintain.

Add one or more validator functions per field inside form() . Use form().valid() or form().invalid() to reactively check the form's overall validity.

bug-report-form.ts
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { form, maxLength, minLength, required } from '@angular/forms/signals';

@Component({
  selector: 'spartan-bug-report-form',
  imports: [],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `...`,
})
export class BugFormsDemo {
  protected readonly _model = signal({
    title: '',
    description: '',
  });

  public readonly form = form(this._model, (schemaPath) => {
    required(schemaPath.title, { message: 'Title must be entered.' });
    minLength(schemaPath.title, 5, { message: 'Title must be at least 5 characters.' });
    maxLength(schemaPath.title, 100, { message: 'Title cannot exceed 32 characters.' });

    required(schemaPath.description, { message: 'Description must be entered.' });
    minLength(schemaPath.description, 20, {
      message: 'Description must be at least 20 characters.',
    });
    maxLength(schemaPath.description, 100, {
      message: 'Description must be at most 100 characters',
    });
  });
}

Displaying Errors

HlmFieldError renders automatically when placed inside an HlmField whose control is invalid. There is no need to wrap it in @if blocks or manually check control state — the field handles visibility for you.

Visibility is driven by the data-matches-spartan-invalid attribute, which spartan sets on the field element. Its value reflects the return value of ErrorStateMatcher.isInvalid() — by default true when the control is invalid and touched. This attribute controls both error styling and the visibility of HlmFieldError messages.

You can change when errors appear by providing a custom ErrorStateMatcher at the component or application level. spartan ships with ShowOnDirtyErrorStateMatcher as a ready-made alternative that shows errors as soon as the control is dirty rather than waiting for a touch event.

When a validator input is specified on HlmFieldError , the message is only shown when the control has an error matching that validator key — letting you place multiple targeted error messages inside a single HlmField , each tied to a specific validation rule.

<hlm-field>
	<label hlmFieldLabel>Bug Title</label>
	<input
		hlmInput
		id="title"
		placeholder="Login button not working on mobile"
		autocomplete="off"
		[formField]="form.title"
	/>
	@for (error of form.title().errors(); track error) {
		<hlm-field-error [validator]="error.kind">{{ error.message }}</hlm-field-error>
	}
</hlm-field>

Set forceShow on HlmFieldError to always display the message, regardless of the control's error state or the active ErrorStateMatcher . This is useful for cross-field validation errors that are not tied to a specific control, such as a password mismatch message.

<hlm-field>
	<label hlmFieldLabel for="title">Bug Title</label>
	<input hlmInput id="title" [formField]="form.title" />
	<hlm-field-error forceShow>This error is always visible.</hlm-field-error>
</hlm-field>

Submission state

Whenever submit() is called - whether through FormRoot or directly - form().submitting() will be true for the duration of the submission. Use this to disable the submit button and show a loading indicator.

<button hlmBtn type="submit" [disabled]="form().submitting()">
  @if (form().submitting()) {
    <hlm-spinner />
  }
  Submit
</button>

Working with Different Field Types

Input

Profile Settings

Update your profile information below.

This is your public display name. Must be between 3 and 10 characters. Must only contain letters, numbers, and underscores.

Textarea

Personalization

Customize your experience by telling us more about yourself.

Tell us more about yourself. This will be used to help us personalize your experience.

Select

Language Preferences

Select your preferred spoken language.

For best results, select the language you speak.

Checkbox

Notifications

Manage your notification preferences.

Responses Get notified for requests that take time, like research or image generation.
TasksGet notified when tasks you've created have updates.

Radio Group

Subscription Plan

See pricing and features for each plan.

PlanYou can upgrade or downgrade your plan at any time.

Switch

Security Settings

Manage your account security preferences.

Enable multi-factor authentication to secure your account.

Complex Forms

You're almost there!

Choose your subscription plan and billing period.

Subscription PlanChoose your subscription plan.
Choose how often you want to be billed.
Add-onsSelect additional features you'd like to include.Advanced analytics and reportingAutomated daily backups24/7 premium customer support
Receive email updates about your subscription

Resetting the form

Use form.reset({...}) to reset the form to its default values and mark all controls as pristine and untouched.

<button hlmBtn variant="outline" type="button" (click)="reset()">
	Reset
</button>

reset() {
  this.form().reset({
    // provide default values
    responses: true,
    tasks: [],
  });
}
Stack Reactive Forms