Page loaded: Template-Driven, Reactive or Signal Forms?

Photo by Elibet Valencia Muñoz on Unsplash

State & Reactivity

Template-Driven, Reactive or Signal Forms?

Angular has three ways to build a form, and on a small form the user can't tell them apart. Choose by where the form's state should live and who has to test it, then use one way across the codebase.

Miguel Carino
Focus
Front-end web architecture, Angular, team leadership, and stakeholder communication
Experience
25+ years turning enterprise web complexity into maintainable products

"Why is this form template-driven when every other form in the app is reactive?"

It's the comment I'd expect on any pull request that mixes the two styles, and it's a fair one. Angular, Google's framework for building web apps, gives you more than one way to build a form. A template-driven form declares its fields in the HTML template with ngModel, a directive that binds an input to a property on the component. A reactive form builds the same fields in the component's TypeScript class, as a FormGroup of FormControl objects, and the template only points at them. Signal Forms, the newest option and stable since Angular 22, build the form from a signal, Angular's reactive value holder, that contains the data.

Most codebases settle on one by habit. The habit holds until a reviewer asks why, and "that's how we do it" is an answer a reviewer doesn't have to accept.

So I built the question into my demo site, demo.miguelcarino.com, a public Angular app I keep as working proof for the posts on this blog. The template-driven vs reactive section has one small sign-up form built twice, side by side: a name of at least two characters, a valid email and an "I agree to the demo terms" box that has to be ticked. The rules and the components are identical. Only the form API changes.

One form, two homes for its state

The template-driven version keeps its rules where the inputs are. Each attribute is a validator directive, and each ngModel creates its own control. The email field, trimmed:

<input
  #email="ngModel"
  id="td-email"
  foo-input
  type="email"
  name="email"
  autocomplete="email"
  [(ngModel)]="model.email"
  required
  email
/>

The class behind it holds a plain object for the two-way bindings, { name: '', email: '', terms: false }, and no controls at all.

The reactive version turns that around. The class builds the whole form, rules included, before the template exists (trimmed to the one member):

export class SignupReactiveForm {
  protected readonly form = new FormGroup({
    name: new FormControl('', {
      nonNullable: true,
      validators: [Validators.required, Validators.minLength(2)],
    }),
    email: new FormControl('', {
      nonNullable: true,
      validators: [Validators.required, Validators.email],
    }),
    terms: new FormControl(false, { nonNullable: true, validators: Validators.requiredTrue }),
  });
}

Its template binds each input by name, with formControlName="email" where the other one had [(ngModel)].

Fill both in with "Ada", "ada@example.com" and a ticked box, and the readouts under the two forms match character for character:

{
  "value": {
    "name": "Ada",
    "email": "ada@example.com",
    "terms": true
  },
  "valid": true
}

Type "not-an-email" into either one and you get the same failure. The input gets aria-invalid="true" and an aria-describedby pointing at the message, and the message sits in an element with role="alert", so a screen reader announces it. Neither forms API did that part. It comes from the input and form field in the demo's design system, its own library of shared components. Both templates wire them the same way, with one shared error helper, which is worth knowing before anyone argues that one style is more accessible than the other.

Where they actually differ

The difference shows up before anyone types.

The last line under each form counts its controls at two moments: when the view first renders, and one microtask later. A microtask is the small queue JavaScript drains right after the current code finishes, before anything else gets a turn. The reactive form reports three, then three. The template-driven form reports zero, then three.

That's by design. NgForm, the directive Angular attaches to every <form> once FormsModule is imported, registers each ngModel inside an already resolved promise, so its controls arrive a tick after the template that declares them. Code that reads the form during that first render finds an empty group.

The reactive form is a seating chart drawn before the party. The template-driven form is a room of place cards: you know who's at the table once everyone has sat down, and not a moment before.

Angular's own forms guide names the cost. Testing template-driven forms "requires a detailed knowledge of the change detection process," the pass in which Angular updates the page from the component's data. In practice a unit test has to wait, with await fixture.whenStable(), before there is anything to assert on. A reactive form can be tested with no template at all, because it's an object in the class.

Typing splits the same way. NgForm hands you an untyped FormGroup, so signup.value is any. The reactive form's type comes from its controls. form.value is Partial<{ name: string; email: string; terms: boolean }> (partial, because a disabled control drops out of value), and a typo like form.controls.emial fails the build.

Signal Forms in the same decision

The demo doesn't build a third version, so here is a sketch of one with the same rules. It's my illustration, written as two fields on a component:

protected readonly model = signal({ name: '', email: '', terms: false });

protected readonly signup = form(this.model, (p) => {
  required(p.name);
  minLength(p.name, 2);
  required(p.email);
  email(p.email);
  required(p.terms);
});
<input type="email" [formField]="signup.email" />

The data stays a plain object in a signal, the rules live in a schema function beside it, and the field types come from the model, so signup.emial fails the build here too. On a checkbox, required treats false as empty, so the unticked box fails the way requiredTrue does. Async rules are built in, with validateAsync and validateHttp for a server check. Reactive forms take an async validator function instead, and template-driven forms need a directive class written for the purpose.

The decision, and the criteria behind it

For a sign-up form in a codebase like the demo's, I'd pick reactive forms. Every other form on that page is reactive already, from the group-level rule in Whose Job Is Cross-Field Validation? to the multistep wizard and a custom rating control. The review comment at the top is really about that. One form system per codebase means one testing pattern, one way to read errors and one set of answers for the next developer.

Testability favors the two class-based options. A reactive form or a Signal Form is an object you can test on its own, while the template-driven form only exists once its template renders and a tick passes.

Dynamic forms, where fields come and go at runtime, are where reactive forms have the longest track record, with FormArray, addControl and removeControl. Signal Forms handle the same case by changing the model, an array in the signal, and applying rules to each item with applyEach. A template-driven form can loop with @for, but its shape is then whatever the template rendered last.

Reactive forms also have years of documentation and answered questions behind them, which matters to whoever joins the team next. Signal Forms are new, so fewer teammates will have shipped one, and fewer people have hit the odd case before you.

I'd reach for Signal Forms in a new app built on signals from the start, with no reactive forms to keep. Angular's comparison page points the same way, and it suggests staying with what you have in an existing codebase. If the move comes later, it can happen a field at a time: SignalFormControl lets a signal-based field sit inside an existing FormGroup, and compatForm wraps existing controls in a signal form.

Template-driven forms earn their place on a small form whose class has nothing to say, like a search box or a contact form in an app with no other forms. There the template really is the whole story. In a codebase full of reactive forms, the one template-driven form is the one every reviewer asks about.

Choose for Whoever Maintains the Form

The two demo forms behave the same for the person filling them in, and the demo's browser tests prove it with the same input and identical readouts. So the choice is about the people on the other side: who tests the form, who types against it and who adds the next field. In an existing reactive codebase, that points to reactive. In a new signals-first app, it points to Signal Forms. Then use that one everywhere, so the next form doesn't need a reason.