Code & Development

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.

Angular 22: Replace ControlValueAccessor with Signal Forms

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.


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.


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
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.

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

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 neededconst 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.