StackDevLife
Angular Signals Forms — Replace ReactiveFormsModule in New Projects
Back to Blog

Angular Signals Forms — Replace ReactiveFormsModule in New Projects

Reactive forms were the right solution for 2018. Angular 21 ships Signal-based Forms — no valueChanges, no async pipe, no subscription management. Here's how to replace ReactiveFormsModule in every new component you write.

SB

Sandeep Bansod

March 21, 202610 min read
Share:

You've written the FormGroup. You've subscribed to valueChanges. You've piped it through async in the template. You've unsubscribed in ngOnDestroy. You've forgotten to unsubscribe once and spent forty minutes chasing a ghost subscription that fires after the component is destroyed.

Reactive forms were the right solution for 2018. Angular 21 ships something better. Signal-based Forms replace the observable chain entirely — no valueChanges, no async pipe, no subscription management. This is not a cosmetic API change; it is a different mental model for how form state flows through a component.

What ReactiveFormsModule got wrong

The API is not the problem. The problem is what it forces you to manage.

TypeScript
// The boilerplate you write every time
export class UserFormComponent implements OnInit, OnDestroy {
  form = new FormGroup({
    email:    new FormControl('', [Validators.required, Validators.email]),
    username: new FormControl('', [Validators.required, Validators.minLength(3)]),
    role:     new FormControl('viewer'),
  });

  private destroy$ = new Subject<void>();

  ngOnInit() {
    // Watch email changes — now you're in observable land
    this.form.get('email')!.valueChanges
      .pipe(takeUntil(this.destroy$), debounceTime(300))
      .subscribe(email => this.checkEmailAvailability(email));

    // Watch the whole form for a derived value
    this.form.valueChanges
      .pipe(takeUntil(this.destroy$))
      .subscribe(() => this.updatePreview());
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }

  get isValid() { return this.form.valid; }
  get emailError() { return this.form.get('email')?.errors?.['email']; }
}

Four lifecycle hooks, a Subject, takeUntil, explicit teardown — and you haven't handled loading state, server errors, or submission yet. Signal-based forms collapse this to plain synchronous state that Angular watches automatically.

Signals recap — the three primitives you need

TypeScript
import { signal, computed, effect } from '@angular/core';

// signal() — writable reactive value
const count = signal(0);
count.set(1);           // replace
count.update(n => n+1); // transform

// computed() — derived value, recalculates when dependencies change
const doubled = computed(() => count() * 2);

// effect() — runs a side effect when signals it reads change
effect(() => {
  console.log('count changed to', count());
});

Calling count() with parentheses is what reads the signal and registers the dependency. Angular tracks which signals a computed() or effect() reads, and re-runs only when those specific signals change. No zone.js triggering a full re-render. No observable subscription to manage.

Your first signal-based form field

Angular 21 ships formField() — a signal primitive purpose-built for form inputs. It holds the value, tracks touched/dirty state, and runs validators reactively.

TypeScript
import { Component, signal, computed } from '@angular/core';
import { formField }                   from '@angular/forms/signals';
import { FormsSignalsModule }           from '@angular/forms/signals';

@Component({
  selector:    'app-user-form',
  standalone:  true,
  imports:     [FormsSignalsModule],
  templateUrl: './user-form.component.html',
})
export class UserFormComponent {
  email    = formField('');
  username = formField('');
  role     = formField<'admin' | 'editor' | 'viewer'>('viewer');
}

The template binds directly — no formControlName directive, no FormGroup wrapping:

HTML
<form (ngSubmit)="submit()">
  <div class="field">
    <label>Email</label>
    <input
      type="email"
      [formField]="email"
      placeholder="you@example.com"
    />
    @if (email.touched() && email.error('required')) {
      <span class="error">Email is required</span>
    }
    @if (email.touched() && email.error('email')) {
      <span class="error">Enter a valid email</span>
    }
  </div>

  <div class="field">
    <label>Username</label>
    <input [formField]="username" />
    @if (username.touched() && username.error('minLength')) {
      <span class="error">Minimum 3 characters</span>
    }
  </div>

  <select [formField]="role">
    <option value="viewer">Viewer</option>
    <option value="editor">Editor</option>
    <option value="admin">Admin</option>
  </select>

  <button type="submit" [disabled]="!isFormValid()">Save</button>
</form>

Everything in the template — email.touched(), email.error('required') — is a signal read. Angular tracks them and updates only the DOM nodes that depend on changed values. No ChangeDetectionStrategy.OnPush required. No markForCheck(). Surgical updates by default.

Validation without the validators array

Signal forms use validator functions that are just functions — plain, testable, no special interface:

TypeScript
import { formField, validators } from '@angular/forms/signals';

// Built-in validators
email    = formField('', { validators: [validators.required, validators.email] });
username = formField('', { validators: [validators.required, validators.minLength(3)] });

// Custom validator — plain function, returns null (valid) or error map
const noSpaces = (value: string) =>
  value.includes(' ') ? { noSpaces: 'Username cannot contain spaces' } : null;

username = formField('', {
  validators: [validators.required, validators.minLength(3), noSpaces],
});

Async validation is the same shape — just return a Promise. The debounce option replaces debounceTime(300) inside a valueChanges pipe:

TypeScript
const emailAvailable = async (value: string) => {
  if (!value) return null;
  const taken = await checkEmailAvailability(value);
  return taken ? { emailTaken: 'This email is already registered' } : null;
};

email = formField('', {
  validators:      [validators.required, validators.email],
  asyncValidators: [emailAvailable],
  debounce:        400, // built-in — no more pipe(debounceTime(400))
});

Derived state with computed()

Any piece of state derived from your form fields is a computed() — no subscription, no local variable updated in a callback:

TypeScript
export class UserFormComponent {
  email    = formField('', { validators: [validators.required, validators.email] });
  username = formField('', { validators: [validators.required, validators.minLength(3)] });
  role     = formField<'admin' | 'editor' | 'viewer'>('viewer');

  // Derived: is the whole form valid?
  isFormValid = computed(() =>
    this.email.valid() && this.username.valid() && this.role.valid()
  );

  // Derived: live preview — updates as the user types, zero setup
  profilePreview = computed(() => ({
    email:    this.email.value(),
    username: this.username.value(),
    role:     this.role.value(),
    initial:  this.username.value().charAt(0).toUpperCase(),
  }));

  // Derived: show admin warning when role is admin
  showAdminWarning = computed(() => this.role.value() === 'admin');

  // Derived: character count
  usernameLength = computed(() => this.username.value().length);
}

In the template, every one of these is just a signal read — no | async, no $ suffix convention, no manual unsubscribe:

HTML
<p class="char-count">{{ usernameLength() }} / 20</p>

@if (showAdminWarning()) {
  <div class="warning">
    Admin users have full system access. Confirm this is intentional.
  </div>
}

<!-- Live preview — updates as user types -->
<div class="preview">
  <span class="avatar">{{ profilePreview().initial }}</span>
  <span>{{ profilePreview().username }}</span>
  <span class="role-badge">{{ profilePreview().role }}</span>
</div>

<button [disabled]="!isFormValid()">Save</button>

Form submission and loading state

TypeScript
export class UserFormComponent {
  email    = formField('', { validators: [validators.required, validators.email] });
  username = formField('', { validators: [validators.required, validators.minLength(3)] });
  role     = formField<'admin' | 'editor' | 'viewer'>('viewer');

  isFormValid  = computed(() => this.email.valid() && this.username.valid() && this.role.valid());

  isSubmitting  = signal(false);
  submitError   = signal<string | null>(null);
  submitSuccess = signal(false);

  async submit() {
    if (!this.isFormValid()) return;

    this.isSubmitting.set(true);
    this.submitError.set(null);

    try {
      await this.userService.createUser({
        email:    this.email.value(),
        username: this.username.value(),
        role:     this.role.value(),
      });

      this.submitSuccess.set(true);
      this.email.reset();
      this.username.reset();
      this.role.reset();

    } catch (err) {
      this.submitError.set(
        err instanceof Error ? err.message : 'Something went wrong'
      );
    } finally {
      this.isSubmitting.set(false);
    }
  }
}
HTML
<button
  type="submit"
  [disabled]="!isFormValid() || isSubmitting()"
>
  {{ isSubmitting() ? 'Saving...' : 'Save user' }}
</button>

@if (submitError()) {
  <p class="error">{{ submitError() }}</p>
}

@if (submitSuccess()) {
  <p class="success">User created successfully.</p>
}

Complex forms — nested groups and dynamic arrays

formGroup() composes multiple fields into a typed group for nested objects:

TypeScript
import { formGroup, formField, validators } from '@angular/forms/signals';

export class CheckoutFormComponent {
  address = formGroup({
    line1:    formField('', { validators: [validators.required] }),
    line2:    formField(''),
    city:     formField('', { validators: [validators.required] }),
    postcode: formField('', { validators: [validators.required, validators.pattern(/^\d{5,6}$/)] }),
    country:  formField('IN'),
  });

  isValid = computed(() => this.address.valid());

  fullAddress = computed(() =>
    `${this.address.fields.line1.value()}, ${this.address.fields.city.value()}`
  );
}

formArray() handles dynamic lists. lineItems.fields() is itself a signal — @for re-renders only items that changed, not the whole list:

TypeScript
import { formArray, formGroup, formField } from '@angular/forms/signals';

export class InvoiceFormComponent {
  lineItems = formArray([ this.createLineItem() ]);

  private createLineItem() {
    return formGroup({
      description: formField('', { validators: [validators.required] }),
      quantity:    formField(1,  { validators: [validators.min(1)] }),
      unitPrice:   formField(0,  { validators: [validators.min(0)] }),
    });
  }

  addItem()              { this.lineItems.push(this.createLineItem()); }
  removeItem(i: number)  { this.lineItems.removeAt(i); }

  total = computed(() =>
    this.lineItems.fields().reduce((sum, item) =>
      sum + (item.fields.quantity.value() * item.fields.unitPrice.value()), 0
    )
  );
}
HTML
@for (item of lineItems.fields(); track $index) {
  <div class="line-item">
    <input [formField]="item.fields.description" placeholder="Description" />
    <input [formField]="item.fields.quantity"    type="number" />
    <input [formField]="item.fields.unitPrice"   type="number" />
    <button type="button" (click)="removeItem($index)">Remove</button>
  </div>
}

<button type="button" (click)="addItem()">Add item</button>
<p class="total">Total: {{ total() | number:'1.2-2' }}</p>

Migrating from ReactiveFormsModule

Signal forms and reactive forms coexist in Angular 21. Start with new components. Migrate existing forms when you touch them for a feature change. One critical note: remove ReactiveFormsModule from the imports of any component that has fully migrated. FormsSignalsModule is the only import a fully migrated component needs.

Replacement map:

TypeScript
// ReactiveFormsModule  →  Signal forms
// new FormControl(val) →  formField(val)
// new FormGroup({...}) →  formGroup({...})
// new FormArray([...]) →  formArray([...])
// valueChanges.pipe() →  computed(() => field.value())
// Validators.required  →  validators.required
// AsyncValidatorFn     →  async (val) => null | ErrorMap
// control.patchValue() →  field.set()
// control.reset()      →  field.reset()
// control.valid        →  field.valid()
// control.errors?.[]   →  field.error('key')
// takeUntil(destroy$)  →  nothing — signals clean up automatically

Common mistakes

  • Forgetting the parentheses on signal reads in templates — email.value is the signal object; email.value() is the current string. [disabled]="!isFormValid" always evaluates to false because the computed function reference is truthy. Always call signals with ()
  • Putting computed() inside methods — computed() must be defined at class field level, not inside event handlers. computed() inside a method creates a new computation on every call, breaking dependency tracking
  • Using effect() to sync form state into another signal — if you find yourself writing effect(() => { this.preview.set(this.email.value()) }), that's what computed() is for. effect() is for side effects (API calls, logging), not for deriving state
  • Forgetting standalone: true and FormsSignalsModule in imports — signal forms do not work with NgModule-based components. The component must be standalone and FormsSignalsModule must be in its imports array
  • Running async validators on every keystroke without debounce — always set debounce: 400 on fields with async validators that make API calls
  • Destructuring signal values instead of signal references — const email = this.email.value() captures a snapshot, not the signal. Pass the signal reference into computed(), not its current value

The takeaway

Signal-based Forms are not just a nicer syntax for reactive forms — they eliminate an entire category of bugs. The subscription lifecycle that caused ghost updates, the takeUntil pattern that gets forgotten once, the async pipe that makes templates hard to read, the derived state that requires a subscription to stay in sync — all of it goes away. Your form component becomes a set of signal fields, computed() derivations, and one async submit() method. Angular handles change detection surgically without you touching ChangeDetectionStrategy, markForCheck(), or detectChanges(). For new Angular projects in 2026, ReactiveFormsModule should not appear in any new component.

SB

Sandeep Bansod

I'm a Front‑End Developer located in India focused on website look great, work fast and perform well with a seamless user experience. Over the years I worked across different areas of digital design, web development, email design, app UI/UX and developemnt.

Related Articles

You might also enjoy these

The Right Way to Structure a Node.js Monorepo in 2026

You split your backend into separate repos. Now you have twelve repos, nine package.json files with slightly different dependency versions, four copies of your validation utils, and six CI pipelines to coordinate for one feature. Here's the monorepo setup that actually works.

Read

Stay in the loop

Get articles on technology, health, and lifestyle delivered to your inbox.No spam — unsubscribe anytime.