Reactive Forms

Build forms in Angular using Reactive Forms.

In this guide, we will take a look at building forms with Reactive 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 Reactive 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 FormBuilder for the form state management and validation.
  • HlmField components for building accessible forms.
  • formControlName="control" binds the form control to the input element.

Anatomy

Here's a basic example of a form using the HlmField component and binding it via formControlName 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"
		formControlName="title"
	/>
	<hlm-field-error validator="required">Title is a required field.</hlm-field-error>
	<hlm-field-error validator="minlength">Title must be at least 5 characters.</hlm-field-error>
	<hlm-field-error validator="maxlength">Title cannot exceed 32 characters.</hlm-field-error>
</hlm-field>

Form

Create form controls

We'll start by defining the shape of our form using the form builder.

bug-report-form.ts
import { Component, inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
	selector: 'bug-report-form',
	imports: [],
	host: { class: 'w-full sm:max-w-md' },
	template: `...`,
})
export class BugReportForm {
	private readonly _fb = inject(FormBuilder);

	public form = this._fb.group({
		title: ['', [Validators.required, Validators.minLength(5), Validators.maxLength(32)]],
		description: ['', [Validators.required, Validators.minLength(20), Validators.maxLength(100)]],
	});
}

Setup the form

Next, we'll import ReactiveFormsModule and bind the form schema to the form element.

bug-report-form.ts
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';

@Component({
	selector: 'bug-report-form',
	imports: [ReactiveFormsModule],
	host: { class: 'w-full sm:max-w-md' },
	template: `
		<form [formGroup]="form" (ngSubmit)="submit()">
			<!-- Build the form here -->
		</form>
	`,
})
export class BugReportForm {
	private readonly _fb = inject(FormBuilder);

	public form = this._fb.group({
		title: ['', [Validators.required, Validators.minLength(5), Validators.maxLength(32)]],
		description: ['', [Validators.required, Validators.minLength(20), Validators.maxLength(100)]],
	});

	submit() {
		// Do something with the form values.
		console.log(this.form.value);
	}
}

Build the form

We can now build the form using HlmField component and bind the form controls using formControlName .

bug-report-form.ts
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
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-reactive-forms-demo',
	imports: [
		ReactiveFormsModule,
		HlmCardImports,
		HlmFieldImports,
		HlmInputImports,
		HlmInputGroupImports,
		HlmButtonImports,
	],
	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>
				<form id="form-bug-report" [formGroup]="form" (ngSubmit)="submit()">
					<hlm-field-group>
						<hlm-field>
							<label hlmFieldLabel for="title">Bug Title</label>
							<input
								hlmInput
								id="title"
								placeholder="Login button not working on mobile"
								autoComplete="off"
								formControlName="title"
							/>
							<hlm-field-error validator="required">Title must be entered.</hlm-field-error>
							<hlm-field-error validator="minlength">Title must be at least 5 characters.</hlm-field-error>
							<hlm-field-error validator="maxlength">Title cannot exceed 32 characters.</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"
									formControlName="description"
								></textarea>
								<hlm-input-group-addon align="block-end">
									<span hlmInputGroupText>{{ form.controls.description.value?.length || 0 }}/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>
							<hlm-field-error validator="required">Description must be entered.</hlm-field-error>
							<hlm-field-error validator="minlength">Description must be at least 20 characters.</hlm-field-error>
							<hlm-field-error validator="maxlength">Description cannot exceed 100 characters.</hlm-field-error>
						</hlm-field>
					</hlm-field-group>
				</form>
			</div>
			<hlm-card-footer>
				<hlm-field orientation="horizontal">
					<button hlmBtn variant="outline" type="button" (click)="form.reset()">Reset</button>
					<button hlmBtn type="submit" form="form-bug-report">Submit</button>
				</hlm-field>
			</hlm-card-footer>
		</hlm-card>
	`,
})
export class ReactiveFormsDemo {
	private readonly _fb = inject(FormBuilder);

	public form = this._fb.group({
		title: ['', [Validators.required, Validators.minLength(5), Validators.maxLength(32)]],
		description: ['', [Validators.required, Validators.minLength(20), Validators.maxLength(100)]],
	});

	submit() {
		if (this.form.invalid) {
			this.form.markAllAsTouched();
			return;
		}

		console.log('You submitted the following values:', JSON.stringify(this.form.value, null, 2));
	}
}

Done

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

When you submit the form, the ngSubmit emits an event. Check if the form data is form.invalid and use form.markAllAsTouched() to mark all controls as touched. This will trigger the display of validation errors next to each field.

Validation

Reactive 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 in the Validators class such as Validators.required or Validators.email .

Add one or more validator functions to your form schema using the form builder. If the form data is invalid, you can check the form's validity using form.valid and form.invalid .

bug-report-form.ts
import { Component, inject } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
	selector: 'bug-report-form',
	imports: [],
	host: { class: 'w-full sm:max-w-md' },
	template: `...`,
})
export class BugReportForm {
	private readonly _fb = inject(FormBuilder);

	public form = this._fb.group({
		title: ['', [Validators.required, Validators.minLength(5), Validators.maxLength(32)]],
		description: ['', [Validators.required, Validators.minLength(20), Validators.maxLength(100)]],
	});
}

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"
		formControlName="title"
	/>
	<hlm-field-error validator="required">Title is a required field.</hlm-field-error>
	<hlm-field-error validator="minlength">Title must be at least 5 characters.</hlm-field-error>
	<hlm-field-error validator="maxlength">Title cannot exceed 32 characters.</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" formControlName="title" />
	<hlm-field-error forceShow>This error is always visible.</hlm-field-error>
</hlm-field>

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.

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)="form.reset()">
	Reset
</button>
Signal Forms Forms