Home Blog About
Angular Signal Forms: What Changes and How to Use Them

Angular Signal Forms: What Changes and How to Use Them

14 min read angular signals typescript forms
Table of Contents

Angular has changed a lot, and for the better. Reactive Forms have been the standard for years, but they carry the baggage of a pre-Signals world: verbosity, a hard dependency on RxJS, and typing that sometimes feels forced.

Since Signals landed, we all wondered whether we could use them to build forms. That day is here: as of Angular v21 we have Signal Forms (experimental at first, stable since Angular v22) to simplify building forms and managing their state.

1. The Building Blocks

A Signal Form isn't a set of pieces you have to assemble. It's a chain: you start with a signal and each step transforms the previous one.

A signal wrapped by form() derives a FieldTree; calling a node gives its FieldState; formField binds it to the input

The model is a signal holding the raw data. It's the single source of truth, and you don't replicate its shape in any FormGroup.

form() is the factory. It takes that signal and returns a FieldTree typed with the same shape as the model. It doesn't copy the data: the signal you pass in stays the owner of the truth. Its second argument is a schema, where you declare the rules.

The FieldTree is how you navigate. You write form.contact.email and the compiler autocompletes the path and warns you if the field doesn't exist.

The FieldState appears when you call a node of the tree: form.contact.email(). That's where the field's signals live: value(), valid(), touched(), errors(). A tree node is the path; calling it asks for its state right now.

The [formRoot] and [formField] directives close the chain and tie all of that to the HTML elements.

That split between the tree and the state (the path and what sits at the end of the path) is the design decision that takes the most getting used to if you come from Reactive Forms, and everything else hangs off it.

2. The Scenario: A Registration Form

Our example is a RegistrationForm. The first thing you'll notice is that the form is "born" from a typed signal. There's no new FormGroup or anything like it:

// registration-form.ts
import { signal, Component, ChangeDetectionStrategy } from "@angular/core";
import { form, FormField, FormRoot, required, email } from "@angular/forms/signals";

// This is our data model, the shape of our form
interface RegistrationData {
  firstName: string;
  lastName: string;
  contact: { email: string; phone: string };
  addresses: { street: string; city: string }[];
  notifications: boolean;
}

@Component({
  selector: "app-registration-form",
  imports: [FormField, FormRoot],
  templateUrl: "./registration-form.html"
})
export class RegistrationFormComponent {
  // 1. Our single source of truth (the model)
  protected readonly registrationModel = signal<RegistrationData>({
    firstName: "",
    lastName: "",
    contact: { email: "", phone: "" },
    addresses: [{ street: "", city: "" }],
    notifications: true,
  });

  // 2. The form engine and its rules
  protected readonly registrationForm = form(this.registrationModel, (f) => {
    required(f.firstName);
    required(f.lastName);
    email(f.contact.email);
  });
}

3. Accessing a Field: FieldTree vs FieldState

We covered the theory above; this is where it shows in day-to-day work. Compare how you used to reach a field and how you do it now:

// ❌ Before (Reactive Forms): registrationForm was a FormGroup
const emailControl = this.reactiveForm.get("contact.email");
const emailValue = emailControl?.value; // string | null | undefined. Who knows.

// βœ… Now (Signal Forms): registrationForm is a FieldTree
const emailState = this.registrationForm.contact.email(); // call the node to read its state
const currentEmail = emailState.value(); // string, typed
const isInvalid = emailState.invalid(); // boolean

4. Mutating Data: Full Granularity

The detail that changes how you work: inside a FieldState, the value property is a WritableSignal, while the rest of the state (valid(), touched(), errors()) are read-only signals. Depending on what you want to change, you have two paths.

A single field β†’ go through its state. Since value is a WritableSignal, use .set() or .update():

this.registrationForm.firstName().value.set("Arcadio");

The structure (adding or removing array items, replacing a whole object) β†’ go through the model. An array is a plain array: there's no FormArray, no push(), no removeAt(). You use spread and filter, and the FieldTree re-derives itself:

addAddress() {
  this.registrationModel.update(v => ({
    ...v,
    addresses: [...v.addresses, { street: '', city: '' }]
  }));
}

removeAddress(index: number) {
  this.registrationModel.update(v => ({
    ...v,
    addresses: v.addresses.filter((_, i) => i !== index)
  }));
}

In both cases the change propagates in both directions (field β†’ model and model β†’ fields), and Angular repaints only the part of the UI that depended on the data that changed. Goodbye to FormArray.push(), patchValue(), and remembering to emit events.

5. What We Do in the Template

To connect the form to the view there are two directives. [formField] goes on each input and ties it to its field in the tree. [formRoot] goes on the form tag and does two things for you: it sets novalidate (silencing the browser's native validation so yours wins) and wires the form's submit into Signal Forms, so you don't have to call preventDefault() by hand.

<!-- registration-form.html -->
<form [formRoot]="registrationForm">
  <div>
    <label>First Name</label>
    <!-- Pass the FieldTree directly -->
    <input [formField]="registrationForm.firstName" />

    <!-- Call the state () to evaluate the error reactively -->
    @if (registrationForm.firstName().invalid() && registrationForm.firstName().touched()) {
    <p class="text-red-600">First name is required.</p>
    }
  </div>

  <!-- Iterate the form's array. track $index is fine for the example;
       if you reorder or remove from the middle, use a stable key from the model -->
  @for (dir of registrationForm.addresses; track $index) {
  <div>
    <input [formField]="dir.street" placeholder="Street" />
    <input [formField]="dir.city" placeholder="City" />
    <button type="button" (click)="removeAddress($index)">Remove</button>
  </div>
  }

  <button type="button" (click)="addAddress()">Add address</button>

  <button type="submit" [disabled]="registrationForm().invalid()">Register</button>
</form>

The template is typed too. If you write [formField]="registrationForm.wrongField", Angular's compiler warns you before you even reach the browser.

6. Validation: What Comes Built In

Rules are declared inside the schema function you pass to form(). Each one targets a field in the tree, so the compiler warns you if you get the path wrong:

import { form, required, email, minLength, pattern } from "@angular/forms/signals";

protected readonly registrationForm = form(this.registrationModel, (f) => {
  required(f.firstName, { message: "First name is required" });
  minLength(f.firstName, 2);

  required(f.contact.email);
  email(f.contact.email);

  pattern(f.contact.phone, /^\+?[0-9]{9,15}$/);
});

v22 broadened the catalog. Besides required and email, you now have min / max for numbers, minLength / maxLength, pattern, and minDate / maxDate for dates. And they don't only validate: disabled, readonly, and hidden control the field's state reactively, and debounce delays how often the input writes to the model (exactly what you used to do with RxJS's debounceTime).

Something that didn't exist before: the limits accept a value or a function. So a maximum can depend on another field, without writing a validator by hand:

// The "guests" max never exceeds the available seats
max(f.guests, () => f.availableSeats().value());

And when a rule doesn't fit any of the above, validate() lets you write your own:

validate(f.lastName, ({ value }) =>
  value().includes(" ") ? { kind: "no-spaces", message: "No spaces" } : undefined
);

What If Validation Gets Complex?

When validation gets large, or you'd rather keep it in one place and share it with the backend, declaring it field by field isn't enough. That's what Standard Schemas are for: you can plug a Zod schema in as one more rule with validateStandardSchema(f, UserSchema). It sits alongside the built-in functions, and the errors from both end up in the same errors(). It's the extra for when the built-ins fall short, not the starting point.

7. Submitting

For submission, Signal Forms ships a submit() helper that does more than check valid(): it marks every field as touched (so hidden errors surface), exposes a submitting() signal while your action runs, and blocks concurrent submissions so a double click doesn't fire two requests.

The cleanest way is to declare the submission logic when you create the form, as the third argument to form():

protected readonly registrationForm = form(
  this.registrationModel,
  (f) => {
    required(f.firstName);
    email(f.contact.email);
  },
  {
    submission: {
      // Runs only if the form is valid.
      // The model is typed: no undefined fields, no any.
      action: async (form) => {
        console.log("Data to submit:", form().value());
        return []; // no server errors
      },
    },
  }
);

Since the form already carries its submission, [formRoot] fires the submit on its own. In the template you don't need any handler: a type="submit" button is enough.

<form [formRoot]="registrationForm">
  <!-- ...fields... -->
  <button type="submit" [disabled]="registrationForm().invalid()">Register</button>
</form>

If the backend rejects something (an email that's already registered, say), the action returns the error and submit() attaches it to the right field. Server-side and client-side validation end up in the same errors().

Note: if you'd rather not put the logic in form(), you can trigger the submit yourself by calling submit(this.registrationForm, async () => { ... }) from a button (click) or the form's (submit). Same function, same benefit; only where the action lives changes.

Bonus: Beyond the Basic Flow

The seven sections above cover most of the forms you'll build. These two extras handle cases that were awkward with Reactive Forms.

Custom Controls Without ControlValueAccessor

If you've ever written a custom control with ControlValueAccessor, you know the ritual: implement writeValue, registerOnChange, registerOnTouched, setDisabledState, and register an NG_VALUE_ACCESSOR provider. A lot of boilerplate to, in the end, expose a value.

Signal Forms replaces it with a contract that has a single required property. You implement FormValueControl and expose a value as a model():

import { model, input, booleanAttribute } from "@angular/core";
import { FormValueControl } from "@angular/forms/signals";

@Component({ selector: "app-toggle", /* ... */ })
export class ToggleComponent implements FormValueControl<boolean> {
  // The only required piece: a model() that Signal Forms keeps in sync.
  readonly value = model(false);

  // Optional, if you want to reflect them in the UI:
  readonly touched = input(false);
  readonly disabled = input(false, { transform: booleanAttribute });
}

The contract also exposes invalid and errors as optional inputs (along with readonly, required, min, max, pattern). If you declare them, Signal Forms hands you the field's validation state and your control can paint the red border or the error message without you wiring anything:

readonly invalid = input(false, { transform: booleanAttribute });
readonly errors = input<readonly ValidationError[]>([]);

And that's it. No writeValue, no providers, no registerOnChange. You wire it up with the same [formField] directive as any native input:

<app-toggle [formField]="registrationForm.notifications" />

Search With debounce, Without RxJS

A classic: a search input that shouldn't hit the backend on every keystroke. You used to solve it with an RxJS pipeline over valueChanges:

this.searchControl.valueChanges.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap(q => this.api.search(q))
).subscribe(/* ... */);

With Signal Forms you declare it as one more rule in the schema, and the model's value already arrives spaced out. No subscribe, no remembering to unsubscribe:

searchForm = form(this.searchModel, (f) => {
  debounce(f.query, 300); // the model updates at most every 300ms
});

From there, any effect or httpResource that depends on searchModel().query fires already debounced. No more debounceTime + switchMap.

What's Next

We've gone from a typed model to handling dynamic arrays, validating with the built-in utilities, and submitting to the backend, without losing types at any point.

The mental jump from Reactive Forms to Signal Forms takes effort, but the Angular team has built a really good replacement: so far I haven't found a case I couldn't solve with Signal Forms. The hardest part is usually dynamic validation, fields that depend on one another, and today most of that is solved directly by the library.

To keep pulling the thread:

  • Try building a debug panel that shows registrationForm().errors() in real time.
  • Check the official documentation for the async validators (validateAsync, validateHttp).

Happy coding!

Logo
Arcadio QuinteroSystems Engineer

Sharing practical knowledge on software architecture, Angular best practices, and the evolving landscape of web development.

Β© 2026 Arcadio Quintero. All rights reserved.

Built withAnalog&Angular