logo
2026-04-9 Angular 7 minutes

Signal Forms: Angular Finally Gets Forms Right

I have built a lot of forms. In dental SaaS, forms are the product — patient intake, insurance claims, treatment plans. I have built more complex forms than I care to admit. Multi-step insurance claim workflows where each step depends on the previous one, validation against external APIs for insurance eligibility checks, conditional fields that change based on procedure codes. And for years, Angular’s Reactive Forms got the job done — but never without friction. FormGroup, FormControl, ControlValueAccessor… the ceremony was brutal. You spent more time fighting the form system than solving the actual business problem.

I have lived through every era of Angular forms. Template-driven forms that were fine until you needed anything dynamic. Reactive Forms that gave you control but buried you in boilerplate. And now Signal Forms, which finally gets the abstraction right. This is not an incremental improvement. It is a rethinking of what a form API should look like when signals are the foundation.

Let me walk through what’s new, what’s different, and the non-obvious details that matter in practice.


The form() Function and [formField] Directive

Everything starts with form() from @angular/forms/signals. You pass it an object shape, and it returns a fully reactive form tree.

import { form } from '@angular/forms/signals';
import { required, minLength } from '@angular/forms/signals';

const profileForm = form({
  name: ['', required(), minLength(2)],
  email: ['', required()],
  bio: ['']
});

In the template, you bind fields with the [formField] directive:

<input [formField]="profileForm.controls.name" />
<input [formField]="profileForm.controls.email" />
<textarea [formField]="profileForm.controls.bio"></textarea>

No more formControlName string references. No more hunting for typos that blow up at runtime. The binding is direct and type-safe.


FieldTree: Every Property Is Reactive

This is where things get interesting. The form tree is a FieldTree — every property in your form shape gets its own reactive state. Not just the value. Each field exposes:

const nameField = profileForm.controls.name;

effect(() => {
  console.log('Name value:', nameField.value());
  console.log('Is dirty:', nameField.dirty());
  console.log('Errors:', nameField.errors());
});

Everything is a signal. No .valueChanges observable. No manual subscriptions. The reactive graph just works.


Schema Composition with schema<T>() and apply()

Validators in Signal Forms are not ad-hoc functions you attach to controls. They are composed into schemas using schema<T>() and the apply() function.

import { schema, apply, required, minLength, maxLength, pattern } from '@angular/forms/signals';

const usernameSchema = schema<string>(
  apply(required(), minLength(3), maxLength(20)),
  apply(pattern(/^[a-zA-Z0-9_]+$/))
);

const registrationForm = form({
  username: ['', usernameSchema],
  password: ['', apply(required(), minLength(8))],
});

Schemas are reusable. You define them once, share them across forms, compose them into larger schemas. This is the kind of composability that Reactive Forms never had.


Conditional Validation with applyWhenValue

Here is something I have wanted for years: validators that activate based on sibling field values. In real apps, this is everywhere. “Require the company name field only when the user selects ‘business’ as the account type.” Or in my world at Zuub: “Require the insurance group number only when the patient has secondary coverage.” “Show the pre-authorization fields only when the procedure cost exceeds the auto-approve threshold.” Every dental claim form I built had conditional logic like this, and with Reactive Forms it was always a mess of valueChanges subscriptions toggling validators imperatively.

applyWhenValue makes this declarative:

const accountForm = form({
  accountType: ['personal'],
  companyName: ['', applyWhenValue(
    'accountType',
    (type) => type === 'business',
    required()
  )],
});

When accountType is 'personal', the companyName field has no required validator. When it switches to 'business', the validator activates and the field becomes invalid if empty. The reactive graph handles the transitions automatically.

No more subscribing to one control’s valueChanges to manually toggle validators on another control. That pattern was always fragile and tedious.


Multi-Field (Tree) Validators

Sometimes validation spans multiple fields. “Confirm password must match password.” Signal Forms handles this with tree-level validators that can assign errors to specific fields:

const passwordForm = form(
  {
    password: ['', apply(required(), minLength(8))],
    confirmPassword: ['', required()],
  },
  {
    validators: [
      (tree) => {
        if (tree.controls.password.value() !== tree.controls.confirmPassword.value()) {
          return { confirmPassword: { mismatch: true } };
        }
        return null;
      }
    ]
  }
);

The validator runs at the tree level but the error is assigned to confirmPassword. The template can display it exactly where the user expects it. No more awkward “form-level error” that you have to manually wire into the right spot in the UI.


Async HTTP Validators with validateHttp()

Async validation is where old Reactive Forms really fell apart. You had to manage debouncing yourself, track pending state manually, and handle race conditions. Signal Forms gives you validateHttp() with built-in debouncing and pending state:

import { validateHttp } from '@angular/forms/signals';

const signupForm = form({
  email: ['', required(), validateHttp(
    (value, http) => http.get<boolean>(`/api/check-email?email=${value}`).pipe(
      map(taken => taken ? { emailTaken: true } : null)
    ),
    { debounce: 400 }
  )],
});

While the HTTP request is in flight, signupForm.controls.email.pending() is true. When it resolves, the error (or lack thereof) merges into the reactive error graph. You get correct debouncing, automatic cancellation of stale requests, and pending state — all for free.

Think about any signup form where you need to check if a username or email is already taken. With Reactive Forms, you had to write a custom async validator that managed its own debounce timer, its own cancellation logic, and its own pending flag. It worked, but it was 60 lines of code for something that should have been five. validateHttp is that five-line version.


Custom Controls via FormValueControl<T>

If you have ever implemented ControlValueAccessor, you know the pain. Four methods, registerOnChange, registerOnTouched, a writeValue that fires at weird times. It was a lot of boilerplate for what should be simple.

Signal Forms replaces all of that with FormValueControl<T>. Your custom control just needs to implement a ModelSignal named value:

import { FormValueControl } from '@angular/forms/signals';
import { Component, model } from '@angular/core';

@Component({
  selector: 'app-star-rating',
  template: `
    @for (star of stars; track $index) {
      <span (click)="value.set($index + 1)"
            [class.filled]="$index < value()">
        &#9733;
      </span>
    }
  `
})
export class StarRatingComponent implements FormValueControl<number> {
  value = model<number>(0);
  stars = [1, 2, 3, 4, 5];
}

That is it. No NG_VALUE_ACCESSOR provider. No forwardRef. No four-method interface. Just a model() signal named value, and the [formField] directive handles the rest. This is a dramatic simplification.


Submission as First-Class

Form submission gets proper treatment with the formRoot directive:

<form [formRoot]="registrationForm" (formSubmit)="onSubmit($event)">
  <input [formField]="registrationForm.controls.email" />
  <button type="submit">Register</button>
</form>

The formSubmit event only fires when the form is valid. But what if you want to submit anyway and let the server decide? Use ignoreValidators:

<form [formRoot]="registrationForm"
      [ignoreValidators]="true"
      (formSubmit)="onSubmit($event)">

And here is the best part — server-side errors merge directly into the reactive error graph:

onSubmit(formValue: RegistrationData) {
  this.authService.register(formValue).subscribe({
    error: (response) => {
      this.registrationForm.setErrors(response.error.fieldErrors);
      // { email: { serverError: 'Already registered' } }
      // Now registrationForm.controls.email.errors() includes serverError
    }
  });
}

Server errors are not second-class citizens anymore. They live in the same reactive graph as client-side validation errors.


Zod and Standard Schema Integration

If you are already using Zod or any Standard Schema-compliant library, you can plug it directly into Signal Forms with validateStandardSchema():

import { z } from 'zod';
import { validateStandardSchema } from '@angular/forms/signals';

const UserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().min(18),
});

const userForm = form({
  name: [''],
  email: [''],
  age: [0],
}, {
  validators: [validateStandardSchema(UserSchema)]
});

One schema. One source of truth. Validation logic shared between your form and your API layer.


No undefined — Use Mapping Functions

Signal Forms takes a firm stance: undefined is not a valid form value. Every field must have a defined initial value. This is intentional.

Your domain model and your form model are different things. A User object might have optional fields, nullable properties, computed values. A form has strings, numbers, booleans — all with concrete defaults.

// Domain model
interface Property {
  title: string;
  price: number | null;
  description?: string;
}

// Mapping functions
function toFormModel(property: Property) {
  return {
    title: property.title,
    price: property.price ?? 0,
    description: property.description ?? '',
  };
}

function toDomainModel(formValue: typeof propertyForm.value): Property {
  return {
    title: formValue.title,
    price: formValue.price || null,
    description: formValue.description || undefined,
  };
}

This separation is a feature, not a limitation. It forces you to think about the boundary between UI state and domain state, which prevents an entire category of bugs.


Metadata for Proactive UI Hints

Signal Forms includes a metadata system that lets you expose field constraints to the UI before the user types anything:

const nameField = profileForm.controls.name;

const meta = nameField.metadata();
// { required: true, minLength: 2, maxLength: undefined, pattern: undefined }
<input [formField]="profileForm.controls.name" />
<small>Minimum {{ profileForm.controls.name.metadata().minLength }} characters</small>

Instead of waiting for the user to fail validation and then showing an error, you can proactively show what the field expects. Better UX with zero extra work.


The Non-Obvious Insight: Schema-Level Behaviors

Here is the thing that took me a moment to fully grasp. In Signal Forms, disabled, hidden, and readonly are schema-level behaviors, not template-level attributes.

const editForm = form({
  role: ['viewer'],
  adminNotes: ['', {
    disabled: (tree) => tree.controls.role.value() !== 'admin',
    hidden: (tree) => tree.controls.role.value() === 'viewer',
  }],
});

The schema declares when a field is disabled. The [formField] directive enforces it by setting the actual DOM attribute. You do not sprinkle [disabled]="someCondition" across your template. The form definition is the single source of truth for field behavior.

This is a fundamental shift. Your template becomes a pure layout concern. All the conditional logic — when is this required, when is it disabled, when is it hidden — lives in the schema where it can be tested, composed, and reused.


Final Thoughts

Signal Forms is not a patch on top of Reactive Forms. It is a ground-up rethink built on the right primitive — signals. The result is less boilerplate, better type safety, declarative conditional logic, and first-class async validation.

If you have been fighting FormGroup and ControlValueAccessor for years like I have, this will feel like a weight lifted. The form system finally matches the quality of the rest of modern Angular.

Next up, I will cover state management patterns in modern Angular — from hand-rolled signal services to NgRx Signal Store. Stay tuned.