An Angular FormValueControl updateOn migration can change when a reactive form exposes a custom-control value. On Angular 22.2.2, a minimal control that implements FormValueControl updates a parent FormControl immediately even when that control is configured with updateOn: 'blur'. The equivalent ControlValueAccessor (CVA) keeps the value pending until blur.

This is not only a validation detail. Early values can reach autosave, pricing, confirmation, analytics or API calls before the user leaves the field. This guide builds a parity test, explains what the current failure proves, separates it from an older dirty-state ordering defect, and gives you a safe migration rule while the update-timing issue remains open.

Treat Angular FormValueControl updateOn as a migration contract

Angular’s FormControl documents updateOn as the event on which the control updates itself. The supported values are 'change', 'blur' and 'submit'. Teams often use blur or submit to avoid running expensive validators and side effects on every keystroke.

A custom-control migration therefore needs more than matching the final value and validity. It should preserve the point at which the parent form can observe that value. If a new control emits earlier than the CVA it replaces, subscribers do not have equivalent behavior even when the UI looks correct.

  • Value timing: Does the parent value remain unchanged until the configured event?
  • Touched timing: Does the user interaction mark the control touched at the same boundary?
  • Dirty ordering: Is the form already dirty when valueChanges runs?
  • Side effects: Can autosave, validation or API code run before the intended boundary?

Test these behaviors separately. An earlier Angular forms defect caused FormValueControl to become dirty after a valueChanges callback. Its fix was merged in Angular PR #70555 and backported to 22.1.x. That does not establish updateOn parity; issue #70743 tracks the separate timing problem.

Build the smallest FormValueControl

The reproduction needs a control with no CVA provider and no extra application state. The component below implements only the signals required by FormValueControl. One button changes its local value; the other emits the touch boundary that represents blur.

import {
  ChangeDetectionStrategy,
  Component,
  input,
  model,
  output,
} from '@angular/core';
import type { FormValueControl } from '@angular/forms/signals';

@Component({
  selector: 'app-custom-text-control',
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <button type="button" (click)="setValue()">Set value</button>
    <button type="button" (click)="touch.emit()">Blur</button>
  `,
})
export class CustomTextControl
  implements FormValueControl<string | null>
{
  value = model<string | null>(null);
  touched = input(false);
  touch = output<void>();
  disabled = model(false);
  invalid = input(false);
  required = input(false);

  setValue(): void {
    this.value.set('set-by-custom-control');
  }
}

Bind this component through ordinary reactive-forms interop. The important part is the parent control’s explicit blur strategy.

@Component({
  imports: [ReactiveFormsModule, CustomTextControl],
  template: `
    <form [formGroup]="form">
      <app-custom-text-control formControlName="text" />
    </form>
  `,
})
class UpdateOnHost {
  form = new FormGroup({
    text: new FormControl<string | null>(
      null,
      { updateOn: 'blur' },
    ),
  });
}

Write a CVA parity test for blur

A failing reproduction is useful only when it includes a control path that proves the test itself is sound. Build the same widget as a conventional CVA, call onChange when its value changes, and call onTouched at blur. Bind it to a second FormControl configured with the same update strategy.

it('defers a FormValueControl update until blur', async () => {
  const fixture = TestBed.createComponent(UpdateOnHost);
  await fixture.whenStable();

  const buttons = Array.from(
    fixture.nativeElement.querySelectorAll('button'),
  ) as HTMLButtonElement[];

  buttons[0].click();
  await fixture.whenStable();

  // Expected parity with updateOn: 'blur'.
  expect(fixture.componentInstance.form.value.text).toBeNull();

  buttons[1].click();
  await fixture.whenStable();

  expect(fixture.componentInstance.form.value.text)
    .toBe('set-by-custom-control');
});

Repeat the same assertions against the CVA host. Before blur, the parent value should still be null. After blur, it should equal the value supplied by the custom control. This makes the test a parity gate instead of a one-sided framework assertion.

buttons[0].click();
await fixture.whenStable();
expect(cvaHost.form.value.text).toBeNull();

buttons[1].click();
await fixture.whenStable();
expect(cvaHost.form.value.text).toBe('set-by-cva-control');

Run the suite with the same Angular packages used by the application. The verified harness used Angular 22.2.2, Node.js 24.19.0 and npm 11.9.0. It did not require credentials, a browser service or an external environment.

Read the Angular 22.2.2 result correctly

The four-test harness produced three passes and one expected regression failure:

CheckAngular 22.2.2 resultMeaning
FormValueControl value remains pending before blurFailThe parent already contained set-by-custom-control.
CVA value remains pending before blurPassThe control path and test expectation are valid.
CVA form is dirty inside valueChangesPassExpected ordering.
FormValueControl form is dirty inside valueChangesPassThe earlier dirty-order issue is fixed in this package.

The failure proves a narrow claim: the tested FormValueControl path does not preserve updateOn: 'blur' timing on Angular 22.2.2. It does not prove that every Signal Forms API is unsafe, that submit timing has been reproduced locally, or that Angular will keep this behavior in a future package. Keep those boundaries in the test name and release notes.

It also explains why a normal end-state test can miss the defect. Both controls eventually contain the new value. The observable difference exists only between the value-change action and the blur action—the exact window in which production subscribers may run.

Choose a safe production migration

Use the timing requirement, not API novelty, as the decision boundary. Treat the Angular FormValueControl updateOn test as a release gate for every custom control whose value timing matters.

  1. Keep the CVA when blur or submit timing protects a business invariant, throttles expensive work, or controls when data leaves the browser.
  2. Allow the FormValueControl migration when the parent is intentionally change-driven and an earlier observable value has no behavioral consequence.
  3. Pin the parity test to your upgrade suite. When Angular ships a candidate fix, upgrade in a branch and require both the FVC and CVA paths to pass.
  4. Remove a workaround only after verification. A closed issue or release-note entry is not a substitute for running the test against your component and package lock.

If you are also moving an application toward zoneless change detection, test form timing independently from rendering. The Angular 22 zoneless Reactive Forms guide covers UI refresh behavior; it does not replace this custom-control contract test.

Cover production risks and edge cases

  • Async validators: Assert when the request starts, not only the final error object. Early values can create extra calls or races.
  • Autosave: Use a spy at the persistence boundary and prove it remains untouched before blur.
  • Nested groups: Observe the parent group as well as the leaf control; subscribers may attach at either level.
  • Programmatic writes: Separate user-input timing from setValue and patchValue behavior.
  • IME and accessibility: Keep a real input-focused integration test in addition to the minimal button harness.
  • Submit mode: Do not generalize the local blur result. Add a submit-specific control test if your application relies on updateOn: 'submit'.
  • Package upgrades: Record the exact Angular version in the failure message so a future green result is attributable to a concrete dependency change.

The practical goal is not to reject FormValueControl. It is to make migration observable. A two-path parity suite gives maintainers a safe point to adopt the newer contract as soon as their required timing behavior is verified.

References

Found this useful? Support more practical developer content.

Author

Practical .NET, Angular, Azure, Blazor, and AI engineering for real-world development.

Write A Comment

Ads Blocker Image Powered by Code Help Pro

Ads Blocker Detected!!!

We have detected that you are using extensions to block ads. Please support us by disabling these ads blocker.

Powered By
Best Wordpress Adblock Detecting Plugin | CHP Adblock