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:
- Add
novalidateattribute: Disable browser's native HTML validation (like browser tooltip popups forrequiredortype="email"fields) - Prevent default behavior: Call
event.preventDefault()in the submit handler to prevent the browser from reloading the page - Call
submit()function: Manually invoke the Signal Formssubmit()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:
- Setting
novalidateon the form element to disable browser validation - Preventing default form submission behavior
- 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:
- ✅ No
novalidateattribute needed - ✅ No
(submit)event handler needed - ✅ No
event.preventDefault()call needed - ✅ Cleaner, more declarative template
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.actionsubmit(this.registrationForm);
// Override the submission action for this specific callsubmit(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
// BeforeregistrationForm = form(this.registrationModel, (schemaPath) => { // validation rules});
onSubmit(event: Event) { event.preventDefault(); submit(this.registrationForm, async () => { this.submitToServer(); });}
// AfterregistrationForm = 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: