Angular 22: Replace ControlValueAccessor with Signal FormsCode & Development

Code & Development · 11 Jul 2026

Angular 22: Replace ControlValueAccessor with Signal Forms

Discover how Angular 22 Signal Forms streamline form development, allowing you to build custom controls and reactive state with minimal boilerplate.

Genildo Souza11 Jul 2026Read: 13 min

Angular 22 Signal Forms Overview

  • Signal Forms replace the complex ControlValueAccessor pattern with a simpler, signal-based API for custom form controls.

  • The new form() function creates a reactive FieldTree that mirrors your data model, providing automatic validation and state tracking.

  • Custom components now only need to expose a model signal to integrate with the form system, eliminating the need for boilerplate providers.

  • Signal Forms are fully compatible with existing Reactive Forms, allowing for incremental migration within your application.

  • The model signal acts as the single source of truth, removing the need for manual data extraction or complex subscription management.

The Problem Signal Forms Solve

There is a specific moment every Angular developer knows. You need a custom input component — a date picker, a rating widget, a rich-text field. And you know what's coming: providers array, forwardRef, four interface methods, manual onChange and onTouched callbacks. Every time. For every control.

Signal Forms don't simplify this. They replace it entirely.

The second pain point is state management. In Reactive Forms, the form state lives in FormControl and FormGroup — Observable-based, imperative to update, fragile to type correctly with nested structures. Watching a field change means subscribing to valueChanges, managing the subscription, unsubscribing on destroy. Signal Forms make every piece of state a signal — readable, composable, and cleaned up automatically.

⚠️ Important: ControlValueAccessor is not deprecated in Angular 22. It still works, and existing controls still compile. But for every new control, Signal Forms are the way forward. Angular 22 guarantees full compatibility between the two worlds.

You don't have to migrate everything. You just have to stop writing the old way for anything new.


The New API: Three Primitives

Learn these three and you've learned Signal Forms.

form()

The entry point. Pass a signal (or linkedSignal) holding your model, and a validation function. Returns a typed FieldTree — a mirror of your model shape where every leaf is a reactive field with value, validity, dirty, touched, and pending state.

FormValueControl<T>

The interface your custom component implements instead of ControlValueAccessor. Minimum requirement: expose a value = model<T>() signal. The directive wires everything else — syncs the field's value signal, propagates touched/dirty, handles disabled state.

SignalFormControl

A bridge for incremental migration. Drop a signal-based control into an existing FormGroup without rewriting the parent form. The validation errors and state propagate both ways — signal form into the reactive form tree, and vice versa.

[formField] directive

The template binding directive. Apply it to any element alongside ngModel and it wires the form field state. Sets CSS classes (.ng-valid, .ng-dirty, .ng-touched), manages ARIA attributes, and keeps value in sync automatically.


Building a Form with Validation

The form() function takes your model signal and a schema function. Validators run on the field value — no more passing validator arrays and hoping the types line up.

typescript
import { Component, inject, signal }           from '@angular/core';
import { form, required, minLength, pattern }  from '@angular/forms/signals';
import { UserService }                          from './user.service';

interface RegisterModel {
  username: string;
  email: string;
  password: string;
}

@Component({
  selector: 'app-register',
  standalone: true,
  imports: [SignalFormsModule],
  template: `
    <form (ngSubmit)="submit()">

      <label>Username
        <input [formField]="f.username" />
        @if (f.username().errors()?.required) {
          <span class="error">Required</span>
        }
        @if (f.username().errors()?.minLength) {
          <span class="error">Min 3 characters</span>
        }
        @if (f.username().pending()) {
          <span class="info">Checking availability…</span>
        }
      </label>

      <label>Email
        <input type="email" [formField]="f.email" />
        @if (f.email().errors()?.pattern) {
          <span class="error">Invalid email format</span>
        }
      </label>

      <label>Password
        <input type="password" [formField]="f.password" />
      </label>

      <button type="submit" [disabled]="!f.valid()">Register</button>
    </form>
  `,
})
export class RegisterComponent {
  private userSvc = inject(UserService);

  // Model as a writable signal
  private model = signal<RegisterModel>({
    username: '',
    email:    '',
    password: '',
  });

  // form() generates the FieldTree — typed to RegisterModel
  f = form(this.model, (field) => {
    required(field.username);
    minLength(field.username, 3);

    // Async validator — checks username availability
    field.username.addAsyncValidator(async (value) => {
      if (!value) return null;
      const taken = await this.userSvc.isUsernameTaken(value);
      return taken ? { usernameTaken: true } : null;
    }, { debounce: 400 }); // built-in debounce — no more timer juggling

    required(field.email);
    pattern(field.email, /^[^\s@]+@[^\s@]+\.[^\s@]+$/);

    required(field.password);
    minLength(field.password, 8);
  });

  submit() {
    if (!this.f.valid()) return;
    console.log(this.model()); // typed RegisterModel — no .getRawValue()
  }
}

✅ Key insight: The model signal is the source of truth. form() does not own the data — your signal does. Reading this.model() gives you the current typed value, without .getRawValue(), without .value, without manual extraction. Signal Forms are a lens over your data, not a container for it.


Custom Controls: Before and After

This is the biggest quality-of-life improvement in the entire release.

Before — ControlValueAccessor (~40 lines)

typescript
@Component({
  selector: 'app-rating',
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => RatingComponent),
    multi: true,
  }],
})
export class RatingComponent implements ControlValueAccessor {

  protected value    = 0;
  protected disabled = false;

  private onChange: (v: number) => void = () => {};
  private onTouched: () => void         = () => {};

  writeValue(v: number) {
    this.value = v ?? 0;
  }
  registerOnChange(fn: (v: number) => void) {
    this.onChange = fn;
  }
  registerOnTouched(fn: () => void) {
    this.onTouched = fn;
  }
  setDisabledState(d: boolean) {
    this.disabled = d;
  }

  select(star: number) {
    this.value = star;
    this.onChange(star);
    this.onTouched();
  }
}

After — FormValueControl (3 lines)

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

@Component({
  selector: 'app-rating',
  standalone: true,
})
export class RatingComponent implements FormValueControl<number> {

  // This single signal is the entire API
  value = model(0);

  // Optional inputs — Signal Forms wires them automatically
  // disabled?: InputSignal<boolean>;
  // touched?:  InputSignal<boolean>;
  // name?:     InputSignal<string>;

  select(star: number) {
    this.value.set(star);
    // No manual onChange/onTouched calls needed
    // The directive handles all propagation
  }
}

// Usage in template — identical to before:
// <app-rating [formField]="f.rating" />

The component contract went from 4 mandatory methods + a providers array + a forwardRef call to one signal. That's it. Everything else — synchronization, dirty tracking, touched propagation, disabled state — is handled by the [formField] directive automatically.

Simplified Custom Component Contracts with Signal Forms

The traditional contract requiring 4 mandatory methods, a providers array, and a forwardRef call has been replaced by a single signal property in custom components. This drastically reduces boilerplate and complexity.

All synchronization tasks—such as dirty tracking, touched state propagation, and disabled state handling—are now automatically managed by the formField directive, enhancing developer experience and code clarity.


Editing Server Data: linkedSignal + httpResource

The most powerful pattern in Signal Forms: load data via httpResource, bridge it into the form via linkedSignal, and the form updates automatically whenever the resource reloads.

typescript
import { Component, inject, input }           from '@angular/core';
import { httpResource }                        from '@angular/common/http';
import { linkedSignal }                        from '@angular/core';
import { form, required, minLength }           from '@angular/forms/signals';

interface UserProfile {
  username: string;
  bio:      string;
  website:  string;
}

@Component({
  selector: 'app-edit-profile',
  standalone: true,
  template: `
    @if (userResource.isLoading()) {
      <p>Loading profile...</p>
    } @else {
      <form (ngSubmit)="save()">
        <input [formField]="f.username" placeholder="Username" />
        <textarea [formField]="f.bio" placeholder="Bio"></textarea>
        <input [formField]="f.website" placeholder="Website" />
        <button [disabled]="!f.valid() || !f.dirty()">Save changes</button>
        <button type="button" (click)="reset()">Discard</button>
      </form>
    }
  `,
})
export class EditProfileComponent {
  userId = input.required<number>();

  // 1. Fetch from server — refetches when userId changes
  userResource = httpResource<UserProfile>(
    () => `/api/users/${this.userId()}`
  );

  // 2. Bridge resource → writable signal via linkedSignal
  //    Updates automatically when userResource reloads
  editableProfile = linkedSignal(
    () => this.userResource.value() ?? { username: '', bio: '', website: '' }
  );

  // 3. Form observes editableProfile — resets when profile reloads
  f = form(this.editableProfile, (field) => {
    required(field.username);
    minLength(field.username, 3);
    required(field.bio);
  });

  save() {
    if (!this.f.valid()) return;
    // this.editableProfile() holds the current form values
    console.log('Saving:', this.editableProfile());
  }

  reset() {
    // Force linkedSignal to re-read from the resource
    this.editableProfile.set(this.userResource.value()!);
  }
}

This pattern removes an entire category of code from edit forms: no more ngOnInit with patchValue(), no more subscription to route params to trigger a reload, no more manual reset logic. The reactive graph handles all of it.

Editing Server Data Pattern with Signal Forms outputs input to linkedSignal creates writable signal input to form validates fields form fields reflect signal form resets on reload httpResource: Load server data Fetches UserProfile by userId HTTP_RESOURCE userResource.value() Current server data or empty profile linkedSignal: Bridge resource to form Creates writable signal from userResource LINKED_SIGNAL editableProfile signal Writable signal bridged from resource form: Form observes editableProfile Validates and tracks form state FORM Form fields & validation username, bio with required and minLength
Editing Server Data Pattern with Signal Forms

Cross-Field Validation

Cross-field validation used to require a custom validator at the FormGroup level with an awkward API. In Signal Forms, a validator on one field can directly read any sibling field's signal.

typescript
interface PasswordForm {
  password: string;
  confirm:  string;
}

f = form(signal<PasswordForm>({ password: '', confirm: '' }), (field) => {
  required(field.password);
  minLength(field.password, 8);

  required(field.confirm);

  // Cross-field rule: reads the sibling field's signal directly
  field.confirm.addValidator((confirmValue) => {
    const pwd = field.password.value(); // sibling signal — no workarounds
    return confirmValue !== pwd
      ? { passwordMismatch: true }
      : null;
  });
});

// In the template:
// @if (f.confirm().errors()?.passwordMismatch) {
//   <span>Passwords don't match</span>
// }

Reactive Forms vs Signal Forms

Criterion

Reactive Forms

Signal Forms (Angular 22)

State model

Observable-based — subscribe, unsubscribe, manage lifecycle

Signal-based — read, derive, auto-cleanup ✅

Custom controls

ControlValueAccessor — 4 methods + provider + forwardRef (~40 lines)

FormValueControl — expose model() signal (3 lines) ✅

Nested forms

FormGroup inside FormGroup — type inference breaks at depth

Model shape mirrors naturally — FieldTree is fully typed ✅

Async validation

Return Observable or Promise — manual debounce setup

addAsyncValidator with { debounce: 400 } built-in ✅

Cross-field rules

Custom validator at FormGroup level — awkward API

Read any sibling's signal directly inside the schema ✅

Server data binding

Manual patchValue() after HTTP response

linkedSignal + httpResource — auto-sync ✅

Reading the value

form.getRawValue() or form.value (nulls possible)

model() — always the full typed value, no nulls ✅

Compatibility

Stand-alone

Compatible with CVA and Reactive Forms via SignalFormControl

When to keep

Existing forms that work — don't migrate what isn't broken

All new forms starting today


Migrating Without a Big Bang

The right migration is incremental. Angular 22 guarantees ControlValueAccessor and FormValueControl are mutually compatible — you can have both in the same form without a single workaround.

Signal Forms evolution

Version

Status

v21

Experimental — API unstable between minor versions. Not recommended for production.

v22

Stable — public API. form(), FormValueControl, SignalFormControl, [formField], ngNoCva, and compatForm bridge are all stable.

The four-step migration playbook

Step 1 — Migrate custom controls first. Replace ControlValueAccessor with FormValueControl one control at a time. Angular 22 makes FormValueControl components fully compatible with existing FormGroup parents — no parent changes needed.

Four-Step Migration Playbook

A concise guide to incrementally migrate Angular forms using Signal Forms.

Step 1

Migrate Custom Controls

Replace ControlValueAccessor with FormValueControl one control at a time without changing parent FormGroups.

Step 2

Write New Forms

Develop new forms using Signal Forms to maximize migration ROI; leave working forms unchanged.

Step 3

Use SignalFormControl

Insert signal-based controls into existing FormGroups to enable two-way validation error propagation.

Step 4

Add ngNoCva

Apply ngNoCva directive when mixing legacy and Signal Forms APIs to avoid conflicts.

Step 2 — Write all new forms with Signal Forms. Don't touch working forms. The ROI of migration is in new development — where Signal Forms eliminate setup entirely — not in rewriting forms that already work.

Step 3 — Use SignalFormControl as a bridge. Drop a signal-based control inside an existing FormGroup. Validation errors propagate both ways — no double bookkeeping.

typescript
import { SignalFormControl } from '@angular/forms/signals';
import { FormGroup }         from '@angular/forms';

// Drop a signal-based control into an existing FormGroup — no rewrite needed
const legacyForm = new FormGroup({
  firstName: new FormControl(''),
  lastName:  new FormControl(''),

  // Signal-based control coexists naturally
  nickname: new SignalFormControl('', (field) => {
    required(field);
    minLength(field, 2);
  }),
});

// legacyForm.getRawValue()
// → { firstName: '', lastName: '', nickname: '' }
// nickname behaves exactly like a regular FormControl from the parent's perspective

Step 4 — Add ngNoCva when mixing both APIs on the same element. When [formField] is used on an input that is also inside a [formGroup], both APIs try to control the same element. ngNoCva tells Angular to skip the CVA and let Signal Forms take over.

⚠️ The ngNoCva directive: you only need this when mixing [formField] with [formGroup], formControlName, or ngModel on the same element. If you're using [formField] standalone or on a FormValueControl component, there's no conflict.


When to Use Signal Forms — and When Not To

✅ Reach for Signal Forms when

  • Writing any new form from scratch — this is the default now

  • Building custom form controls — replaces CVA completely

  • The form data comes from an API — linkedSignal + httpResource compose naturally

  • Async validation is required — built-in debouncing, no Observable setup

  • Cross-field validation exists — read sibling signals directly in the schema

  • You want typed .value() without .getRawValue() or null coercion

  • You're in a zoneless app — Signals are the right primitive end to end

When to Use Signal Forms

Writing any new form from scratch (default choice)

Building custom form controls (fully replaces CVA)

Form data sourced from an API (natural with linkedSignal + httpResource)

Async validation needed (built-in debouncing, no Observable setup)

Cross-field validation supported via sibling signals in schema

When to Keep Reactive Forms

Existing forms working without bugs

Complex FormGroup logic with custom status management

Third-party CVA libraries not yet updated

Team requires time to gain intuition before migrating

Simple admin panels or settings screens with no issues

→ Keep Reactive Forms when

  • The form already works and there's no bug to fix

  • Heavily customized FormGroup logic with complex status management

  • Third-party CVA libraries that haven't been updated yet

  • Your team needs time to build intuition — don't rush a big-bang migration

  • Admin panels and settings screens with simple logic and no issues


The Bottom Line

Signal Forms aren't a new way to think about forms. They're the same concepts — validation, state, binding, submission — built on the right primitive. Signals were always going to be the foundation of Angular's reactivity; forms were just the last major surface to catch up.

The practical impact is immediate in two places:

Custom controls: if you have a design system with 10 custom inputs, each one can lose 30–40 lines of boilerplate the next time you touch it.

Form-to-API integration: the httpResource + linkedSignal pattern removes an entire category of manual patchValue() calls and subscription management that littered edit forms in every Angular codebase.

The migration story is genuinely good. Angular 22 doesn't force your hand — ControlValueAccessor still works, FormGroup still works, and the two APIs can coexist in the same form. The right moment to switch is the next new form you write, not a scheduled migration sprint.

The one-sentence decision guide: New form? Use Signal Forms — form(), model(), FormValueControl. Existing form that works? Leave it. Existing form you're touching anyway? Migrate the custom controls first, then consider the form model if it's worth the diff.

The best Angular forms don't feel like Angular forms. They feel like data.


Sources

  • angular.dev/guide/forms/signals/overview — Official Angular Signal Forms guide: form(), [formField], validators, async validation, nested forms.

  • angular.dev/essentials/signal-forms — Essentials overview, FormValueControl interface, custom controls without ControlValueAccessor.

  • angular.dev/guide/forms/signals/migration — Official migration guide from Reactive Forms, compatForm bridge, incremental strategy.

  • Santosh Yadav — santoshyadav.dev (Jun 16, 2026) — "Angular 22: The Signals Are Strong" — ngNoCva, SignalFormControl, OnPush as new default, linkedSignal custom set option.

  • Angular.love (May 26, 2026) — "Signal Forms in Angular 21: Complete Guide" — compatForm, validation model, CVA→FormValueControl comparison.

  • ANGULARarchitects.io (Jun 2026) — "All About Angular's New Signal Forms" — custom validation, schemas, nested forms, form arrays, debouncing.

  • Brian Treese — briantree.se (Mar 26, 2026) — "Angular 22: Mix Signal Forms and Reactive Forms Seamlessly" — FormValueControl migration, incremental strategy.

  • LogRocket Blog (Mar 27, 2026) — "Signal Forms: Angular's best quality of life update in years."

  • Rajat / Substack (Jun 2026) — "Signal Forms Are Finally Production-Ready in Angular 22" — form() API, httpResource + linkedSignal pattern, async validation.

  • Angular_with_Awais / Medium (May 2026) — "Angular v22 Signal Forms: Build Custom Controls Without ControlValueAccessor."


Code examples reflect the Angular 22 Signal Forms stable API as released June 3, 2026. ControlValueAccessor is not deprecated and continues to work in Angular 22. Always refer to angular.dev/guide/forms/signals for the current stable API.