Skip to content

[Angular] Replace ControlValueAccessor with Signal Forms FormUiControl interfaces for Angular form components #6418

@d-koppenhagen

Description

@d-koppenhagen

Currently all form elements such as DBInput, DBSelect, DBTextarea, DBCheckbox, DBRadio, DBSwitch, and DBCustomSelect use Angular's ControlValueAccessor to make inputs/events and other bindings (such as disabled) reactive and provide integration with Angular Forms.

Angular's newest form approach — Signal Forms (introduced as experimental in Angular 21, expected to become stable with Angular 22) — will replace the current form approaches (Template Driven Forms and Reactive Forms) in the future and provides a more simplified API.

Signal Forms introduce new interfaces — FormUiControl (with the concrete variants FormValueControl and FormCheckboxControl) — which allow replacing the old ControlValueAccessor implementation. Under the hood, the new interfaces have the big advantage of being simpler to implement and being compatible with all three form approaches, making them future-proof and backwards compatible with the older form approaches.

Motivation

  • ControlValueAccessor requires implementing four methods (writeValue, registerOnChange, registerOnTouched, setDisabledState) plus providing NG_VALUE_ACCESSOR. This is boilerplate-heavy and error-prone.
  • The new FormValueControl / FormCheckboxControl interfaces leverage Angular Signals (ModelSignal, InputSignal) which the DB UX components already use internally (via model() and input()). This means the components are already very close to the required shape.
  • Signal Forms' FormField directive automatically manages value, disabled, errors, readonly, and hidden on components implementing FormUiControl — no manual wiring needed.
  • The new approach is backwards compatible: components implementing FormValueControl / FormCheckboxControl continue to work with Template Driven Forms and Reactive Forms.

Affected Components

All Angular form components that currently implement ControlValueAccessor and provide NG_VALUE_ACCESSOR:

Component Interface to implement Binding property
DBInput FormValueControl<string> value
DBSelect FormValueControl<string> value
DBTextarea FormValueControl<string> value
DBCustomSelect FormValueControl<string> value
DBCheckbox FormCheckboxControl checked
DBSwitch FormCheckboxControl checked
DBRadio FormValueControl<string> value

Current Implementation (Example: DBInput)

import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  providers: [{
    provide: NG_VALUE_ACCESSOR,
    useExisting: DBInput,
    multi: true
  }],
  // ...
})
export class DBInput implements AfterViewInit, ControlValueAccessor, OnDestroy {
  readonly value = model<string>();
  readonly disabled = model<boolean>();

  writeValue(value: any) {
    this.value.set(value);
    if (this._ref()?.nativeElement) {
      this.renderer.setProperty(this._ref()?.nativeElement, 'value', value);
    }
  }

  propagateChange(_: any) {}

  registerOnChange(onChange: any) {
    this.propagateChange = onChange;
  }

  registerOnTouched(onTouched: any) {}

  setDisabledState(disabled: boolean) {
    this.disabled.set(disabled);
  }
}

Target Implementation (Example: DBInput)

import { FormValueControl, ValidationError } from '@angular/forms/signals';

@Component({
  // No NG_VALUE_ACCESSOR provider needed
  // ...
})
export class DBInput implements AfterViewInit, FormValueControl<string>, OnDestroy {
  // These already exist and satisfy the FormValueControl interface:
  readonly value = model<string>('');
  readonly disabled = input<boolean>(false);

  // Optional: automatically provided by FormField directive
  readonly errors = input<readonly ValidationError[]>([]);
  readonly readonly = input<boolean>(false);
  readonly hidden = input<boolean>(false);

  // writeValue, registerOnChange, registerOnTouched, setDisabledState
  // and propagateChange can all be REMOVED
}

For checkbox-based components (DBCheckbox, DBSwitch):

import { FormCheckboxControl, ValidationError } from '@angular/forms/signals';

@Component({
  // ...
})
export class DBCheckbox implements AfterViewInit, FormCheckboxControl, OnDestroy {
  readonly checked = model<boolean>(false);
  readonly disabled = input<boolean>(false);

  // Optional
  readonly errors = input<readonly ValidationError[]>([]);
  readonly readonly = input<boolean>(false);
  readonly hidden = input<boolean>(false);
}

Scope of Changes

Since this is a Mitosis-based multi-target project, the changes need to happen at the Angular-specific layer:

  1. Mitosis Angular plugin (packages/components/configs/angular/index.cjs and packages/components/configs/plugins/angular/): Update the code generation to produce FormValueControl / FormCheckboxControl implementations instead of ControlValueAccessor.

  2. Angular post-processing scripts: Remove the injection of NG_VALUE_ACCESSOR providers, writeValue, registerOnChange, registerOnTouched, setDisabledState, and propagateChange methods.

  3. form-components.ts utility (packages/components/src/utils/form-components.ts): The handleFrameworkEventAngular function currently calls propagateChange and writeValue — this needs to be adapted or removed for Angular since Signal Forms handle value propagation through the model() signal automatically.

  4. Usage with FormField directive: After migration, consumers can use DB UX form components with Signal Forms like this:

    <db-input [formField]="myForm.username" label="Username" />
    <db-checkbox [formField]="myForm.acceptTerms" label="Accept Terms" />

Backwards Compatibility Strategy

Signal Forms were introduced as experimental in Angular 21 and are expected to become stable with Angular 22. The FormValueControl / FormCheckboxControl interfaces live in @angular/forms/signals, which does not exist in Angular versions < 21. Since @db-ux/ngx-core-components is published as a pre-built npm package and consumed as a dependency, we cannot know the consumer's Angular version at our build time. The compatibility strategy must therefore work at runtime.

Approach: Runtime Detection — Keep Both Implementations, Conditionally Register NG_VALUE_ACCESSOR

The key insight is that TypeScript interfaces are erased at runtime — implements FormValueControl<string> has zero runtime cost and does not create an import dependency that would break on older Angular versions. The actual breaking point is the import { FormValueControl } from '@angular/forms/signals' statement, which would fail on Angular < 21 because the module doesn't exist.

Step 1 — Structurally satisfy FormValueControl / FormCheckboxControl (no import needed)

The DB UX components already use model() for value / checked and input() for disabled. This means they already structurally match the FormValueControl / FormCheckboxControl interfaces. Angular's FormField directive uses duck typing at runtime — it checks for the presence of a value ModelSignal (or checked for checkboxes), not for an explicit implements clause. So the components can work with Signal Forms without importing anything from @angular/forms/signals.

Step 2 — Keep ControlValueAccessor methods but conditionally provide NG_VALUE_ACCESSOR

The ControlValueAccessor methods (writeValue, registerOnChange, etc.) are harmless to keep — they are just class methods that do nothing if nobody calls them. The critical part is the NG_VALUE_ACCESSOR provider: when Signal Forms' FormField directive is used, having NG_VALUE_ACCESSOR registered can cause conflicts because both the old CVA bridge and the new Signal Forms bridge try to manage the same component.

The solution is to conditionally register the NG_VALUE_ACCESSOR provider at runtime using VERSION from @angular/core:

import { VERSION } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';

const angularMajor = parseInt(VERSION.major, 10);
const hasSignalForms = angularMajor >= 21;

@Component({
  providers: [
    // Only register CVA provider when Signal Forms are NOT available
    ...(hasSignalForms ? [] : [{
      provide: NG_VALUE_ACCESSOR,
      useExisting: DBInput,
      multi: true
    }])
  ],
  // ...
})
export class DBInput implements AfterViewInit, OnDestroy {
  // model() / input() signals — structurally compatible with FormValueControl
  readonly value = model<string>('');
  readonly disabled = model<boolean>(false);

  // CVA methods — kept for Angular < 21, harmless no-ops otherwise
  writeValue(value: any) { /* ... */ }
  propagateChange(_: any) {}
  registerOnChange(onChange: any) { this.propagateChange = onChange; }
  registerOnTouched(onTouched: any) {}
  setDisabledState(disabled: boolean) { this.disabled.set(disabled); }
}

VERSION is always available in @angular/core and gives us a reliable way to branch at runtime. The hasSignalForms check can be extracted into a shared utility used by all form components.

This way:

  • On Angular < 21: NG_VALUE_ACCESSOR is registered, CVA works as before, Signal Forms don't exist so no conflict.
  • On Angular ≥ 21: NG_VALUE_ACCESSOR is not registered, the FormField directive picks up the component via its signal-based shape (duck typing), and the CVA methods sit unused.

Step 3 — Adapt handleFrameworkEventAngular

The handleFrameworkEventAngular utility currently calls propagateChange and writeValue. With Signal Forms, value propagation happens automatically through the model() signal. The function should check whether the component is running in CVA mode (i.e. propagateChange has been replaced by a real callback via registerOnChange) before calling it:

export const handleFrameworkEventAngular = (
  component: any,
  event: any,
  modelValue: string = 'value'
): void => {
  // Always update the signal (works for both CVA and Signal Forms)
  component[modelValue]?.set?.(event.target[modelValue]);

  // Only propagate to CVA if registerOnChange was called (Angular < 21 path)
  if (typeof component.propagateChange === 'function') {
    component.propagateChange(event.target[modelValue]);
  }
};

Phase 2 — Full Cleanup (Future Major Version)

Once the minimum supported Angular version is raised to ≥ 22 (Signal Forms stable):

  • Remove the NG_VALUE_ACCESSOR conditional provider entirely.
  • Remove writeValue, registerOnChange, registerOnTouched, setDisabledState, propagateChange.
  • Add explicit implements FormValueControl<T> / implements FormCheckboxControl with the import from @angular/forms/signals.
  • Remove or simplify handleFrameworkEventAngular.
  • This is a major version bump for @db-ux/ngx-core-components.

Compatibility Matrix

Angular Version Signal Forms Status NG_VALUE_ACCESSOR Form Approaches Supported
< 21 Not available Registered (CVA active) Reactive Forms, Template Driven Forms
21 (experimental) Available Not registered (duck-typed) Reactive Forms, Template Driven Forms, Signal Forms
≥ 22 (stable) Available Not registered (duck-typed) Reactive Forms, Template Driven Forms, Signal Forms
≥ 22 (after cleanup) Available Removed entirely Reactive Forms, Template Driven Forms, Signal Forms

References

Metadata

Metadata

Assignees

No one assigned

    Projects

    Status

    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions