angular.courses

Angular FormRoot Directive: Simplified Form Submission in Signal Forms

Angular Signal Forms now includes a new FormRoot directive that simplifies form submission by automatically handling browser form behavior. This directive eliminates the need to manually add novalidate attributes and call event.preventDefault()—common boilerplate that was previously required for every Signal Form.

The Problem: Manual Form Submission Handling

Before the FormRoot directive, developers had to manually handle form submission in Signal Forms:

  1. Add novalidate attribute: Disable browser's native HTML validation (like browser tooltip popups for required or type="email" fields)
  2. Prevent default behavior: Call event.preventDefault() in the submit handler to prevent the browser from reloading the page
  3. Call submit() function: Manually invoke the Signal Forms submit() function to handle validation and reveal errors

Here's what the old approach looked like:

@Component({
template: `
<form novalidate (submit)="onSubmit($event)">
<input [formField]="registrationForm.username" />
<input type="email" [formField]="registrationForm.email" />
<input type="password" [formField]="registrationForm.password" />
<button type="submit">Register</button>
</form>
`,
imports: [FormField],
})
export class Registration {
registrationModel = signal({ username: "", email: "", password: "" });
registrationForm = form(this.registrationModel, (schemaPath) => {
required(schemaPath.username);
email(schemaPath.email);
required(schemaPath.password);
});
onSubmit(event: Event) {
event.preventDefault();
submit(this.registrationForm, async () => {
this.submitToServer();
});
}
private submitToServer() {
// Send data to server
}
}

This approach works, but it requires boilerplate code that must be repeated for every form in your application.

The Solution: FormRoot Directive

The FormRoot directive simplifies form submission by automatically:

  1. Setting novalidate on the form element to disable browser validation
  2. Preventing default form submission behavior
  3. Calling submit() on the FieldTree when the form is submitted

Under the hood, the directive is a thin wrapper that binds your FieldTree to the native <form> and delegates to the existing submit() API:

import { Directive, input } from "@angular/core";
import { submit } from "../api/structure";
import { FieldTree } from "../api/types";
@Directive({
selector: "form[formRoot]",
host: {
novalidate: "",
"(submit)": "onSubmit($event)",
},
})
export class FormRoot<T> {
readonly fieldTree = input.required<FieldTree<T>>({ alias: "formRoot" });
protected onSubmit(event: Event): void {
event.preventDefault();
submit(this.fieldTree());
}
}

But What does it submit() exactly?

It uses the action defined in the form definition, in the submission options:

registrationForm = form(
this.registrationModel,
(schemaPath) => {
required(schemaPath.username);
},
{
submission: {
action: async () => this.submitToServer(),
},
},
);
private submitToServer() {
// Send data to server
}

Basic Usage

Here's how to use the FormRoot directive:

import { Component, signal } from "@angular/core";
import { form, FormRoot, FormField } from "@angular/forms/signals";
import { required, email } from "@angular/forms/signals";
@Component({
template: `
<form [formRoot]="registrationForm">
<input [formField]="registrationForm.username" />
<input type="email" [formField]="registrationForm.email" />
<input type="password" [formField]="registrationForm.password" />
<button type="submit">Register</button>
</form>
`,
imports: [FormRoot, FormField],
})
export class Registration {
registrationModel = signal({ username: "", email: "", password: "" });
registrationForm = form(
this.registrationModel,
(schemaPath) => {
required(schemaPath.username);
email(schemaPath.email);
required(schemaPath.password);
},
{
submission: {
action: async () => this.submitToServer(),
},
},
);
private submitToServer() {
// Send data to server
}
}

Notice the improvements:

Resetting Forms After Submission

The FormRoot directive also simplifies resetting forms after successful submission. You can now pass a value to the reset() method to update the model data:

@Component({
// ... template and imports ...
})
export class Contact {
private readonly INITIAL_MODEL = { name: "", email: "", message: "" };
contactModel = signal({ ...this.INITIAL_MODEL });
contactForm = form(this.contactModel, {
submission: {
action: async (f) => {
await this.api.sendMessage(this.contactModel());
// Clear interaction state (touched, dirty) and reset to initial values
f().reset({ ...this.INITIAL_MODEL });
},
},
});
}

The reset() method accepts an optional value parameter, allowing you to reset both the form state (touched, dirty flags) and the model values in a single call.

Manual Form Submission

You can still submit forms manually without using the directive by calling submit() directly. Passsing an action will override the one defined in the form submission options.

// Submit without options - uses the form's default submission.action
submit(this.registrationForm);
// Override the submission action for this specific call
submit(this.registrationForm, {
action: () => {
// Custom submission logic
},
});

Migration Guide

If you're currently using the manual approach, migrating to FormRoot is straightforward:

Step 1: Add FormRoot to imports

import { FormRoot, FormField } from "@angular/forms/signals";
imports: [FormRoot, FormField];

Step 2: Update your template

// Before
<form novalidate (submit)="onSubmit($event)">
// After
<form [formRoot]="registrationForm">

Step 3: Move submission logic to form options and remove the onSubmit method

// Before
registrationForm = form(this.registrationModel, (schemaPath) => {
// validation rules
});
onSubmit(event: Event) {
event.preventDefault();
submit(this.registrationForm, async () => {
this.submitToServer();
});
}
// After
registrationForm = form(
this.registrationModel,
(schemaPath) => {
// validation rules
},
{
submission: {
action: async () => this.submitToServer(),
},
}
);

Summary

The FormRoot directive simplifies form submission in Angular Signal Forms by eliminating common boilerplate code. Instead of manually adding novalidate, preventing default behavior, and calling submit(), you can now use a single directive that handles everything automatically.

This improvement makes Signal Forms even more developer-friendly while maintaining flexibility for advanced use cases. The directive is backward compatible and existing forms using manual submission continue to work unchanged.

For more information, see the Signal Forms documentation and the field state management guide.


Related Resources:

Angular FormRoot Directive: Simplified Form Submission in Signal Forms