Skip to the solution
MediumFrontend12 min read

How do signal forms and ControlValueAccessor evolve in modern Angular?

350 views

The problem

Question presented to candidate: "Angular 22 shipped Signal Forms as stable. We have a reactive-forms app with a dozen ControlValueAccessor components. Explain how Signal Forms work, what replaces ControlValueAccessor, and how you would migrate without a big-bang rewrite."

What a strong answer should cover:

  • Signal Forms (@angular/forms/signals): you own the data as a signal(), and form(model, schema) builds a typed field tree over it; each field's state (value, valid, errors, touched, dirty, disabled...) is a signal.
  • Rules are declared in a schema function: required, email, minLength, min/max, pattern, validate (with valueOf for cross-field rules), validateAsync/validateHttp, disabled, hidden, readonly, debounce, applyEach, applyWhen.
  • Binding is one directive, [formField]; [formRoot] handles submit (adds novalidate, calls submit()).
  • Custom controls: FormValueControl / FormCheckboxControl replace most CVA code with a value = model() plus optional inputs (errors, disabled, touched...).
  • Interop: existing CVAs work with [formField]; model-based controls also work with [formControl]; SignalFormControl and compatForm let reactive and signal forms be mixed during migration.
  • Status: experimental in v21, stable in v22 (@publicApi 22.0 in the 22.2.3 typings).

Clarifying questions expected:

  • "Which Angular version are we on? Signal Forms are only stable from v22."
  • "Are the CVA components shared with other apps that will stay on reactive forms?"

Code / implementation expected: Yes โ€” a small signal form with validation and submit, and a model()-based custom control next to a legacy CVA.

  • forms
  • controlValueAccessor
  • signals

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

12 min read ยท 9 sections

Read the code

AngularA Signal Forms sign-up form: schema validators, a cross-field rule and FormRoot submission
import { Component, signal } from "@angular/core";
import { form, FormField, FormRoot, required, email, minLength, validate } from "@angular/forms/signals";

interface Signup {
  name: string;
  email: string;
  password: string;
  confirm: string;
}

@Component({
  selector: "app-root",
  imports: [FormField, FormRoot],
  template: `
    <!-- formRoot adds novalidate and calls submit() with the options given to form() -->
    <form [formRoot]="f">
      <input id="name" placeholder="name" [formField]="f.name" />
      <input id="email" placeholder="email" [formField]="f.email" />
      <input id="password" type="password" placeholder="password" [formField]="f.password" />
      <input id="confirm" type="password" placeholder="confirm" [formField]="f.confirm" />
      <button id="save" type="submit">Save</button>
    </form>
    <p id="state">valid={{ f().valid() }} dirty={{ f().dirty() }} touched={{ f().touched() }}</p>
    <p id="errors">{{ errorKinds() }}</p>
    <p id="model">model={{ model().name }}|{{ model().email }}</p>
    <p id="log">{{ log() }}</p>
  `,
})
export class AppComponent {
  // The model is a plain writable signal; form() builds a typed tree of fields over it.
  model = signal<Signup>({ name: "", email: "", password: "", confirm: "" });
  log = signal("");

  f = form(
    this.model,
    (p) => {
      required(p.name, { message: "Name is required" });
      email(p.email);
      minLength(p.password, 8);
      // Cross-field rule: valueOf() reads another field, so it re-runs when either changes.
      validate(p.confirm, ({ value, valueOf }) =>
        value() === valueOf(p.password) ? undefined : { kind: "mismatch", message: "Passwords differ" }
      );
    },
    {
      submission: {
        action: async (root) => {
          this.log.set("saved " + JSON.stringify(root().value()));
          return undefined; // or return errors from the server
        },
        onInvalid: () => this.log.set("blocked: invalid"),
      },
    }
  );

  errorKinds() {
    const fields = [this.f.name, this.f.email, this.f.password, this.f.confirm];
    return fields.flatMap((field) => field().errors().map((e) => e.kind)).join(",") || "none";
  }
}
AngularA FormValueControl built with model() next to a legacy ControlValueAccessor, both bound with [formField]
import { Component, forwardRef, input, model, output, signal } from "@angular/core";
import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR, ReactiveFormsModule, Validators } from "@angular/forms";
import { form, FormField, FormValueControl, ValidationError, max, min } from "@angular/forms/signals";

// NEW: a Signal Forms control. The contract is a model() called value, plus optional
// inputs (errors, disabled, touched...) and a touch output that the forms directive wires up.
@Component({
  selector: "qty-stepper",
  template: `
    <button type="button" class="minus" [disabled]="disabled()" (click)="step(-1)">-</button>
    <span class="qty">{{ value() }}</span>
    <button type="button" class="plus" [disabled]="disabled()" (click)="step(1)">+</button>
    @if (errors().length) { <em class="err">{{ errors()[0].kind }}</em> }
  `,
})
export class QtyStepper implements FormValueControl<number> {
  value = model(0);
  disabled = input(false);
  errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
  touch = output<void>();
  step(delta: number) {
    this.value.update((v) => v + delta);
    this.touch.emit();
  }
}

// OLD: a ControlValueAccessor written for reactive forms, reused unchanged.
@Component({
  selector: "legacy-toggle",
  providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => LegacyToggle), multi: true }],
  template: `<button type="button" class="toggle" (click)="flip()">{{ on() ? "ON" : "OFF" }}</button>`,
})
export class LegacyToggle implements ControlValueAccessor {
  on = signal(false);
  private onChange: (v: boolean) => void = () => {};
  writeValue(v: boolean) { console.log("CVA writeValue(" + v + ")"); this.on.set(!!v); }
  registerOnChange(fn: (v: boolean) => void) { console.log("CVA registerOnChange"); this.onChange = fn; }
  registerOnTouched() { console.log("CVA registerOnTouched"); }
  setDisabledState(d: boolean) { console.log("CVA setDisabledState(" + d + ")"); }
  flip() { this.on.update((x) => !x); this.onChange(this.on()); }
}

@Component({
  selector: "app-root",
  imports: [FormField, ReactiveFormsModule, QtyStepper, LegacyToggle],
  template: `
    <!-- Signal Forms binding both kinds of control -->
    <qty-stepper id="sf-qty" [formField]="f.qty" />
    <legacy-toggle [formField]="f.gift" />
    <p class="model">{{ model().qty }} / {{ model().gift }} / valid={{ f().valid() }}</p>

    <!-- The same new-style control driven by a reactive FormControl, no ControlValueAccessor -->
    <qty-stepper id="rf-qty" [formControl]="legacyQty" />
  `,
})
export class AppComponent {
  model = signal({ qty: 1, gift: true });
  f = form(this.model, (p) => {
    min(p.qty, 1);
    max(p.qty, 3);
  });
  legacyQty = new FormControl(5, { nonNullable: true, validators: Validators.max(5) });
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 67 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextMedium

What is a FormArray?

Continue