-
Notifications
You must be signed in to change notification settings - Fork 17
[Angular] Replace ControlValueAccessor with Signal Forms FormUiControl interfaces for Angular form components #6418
Description
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
ControlValueAccessorrequires implementing four methods (writeValue,registerOnChange,registerOnTouched,setDisabledState) plus providingNG_VALUE_ACCESSOR. This is boilerplate-heavy and error-prone.- The new
FormValueControl/FormCheckboxControlinterfaces leverage Angular Signals (ModelSignal,InputSignal) which the DB UX components already use internally (viamodel()andinput()). This means the components are already very close to the required shape. - Signal Forms'
FormFielddirective automatically managesvalue,disabled,errors,readonly, andhiddenon components implementingFormUiControl— no manual wiring needed. - The new approach is backwards compatible: components implementing
FormValueControl/FormCheckboxControlcontinue 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:
-
Mitosis Angular plugin (
packages/components/configs/angular/index.cjsandpackages/components/configs/plugins/angular/): Update the code generation to produceFormValueControl/FormCheckboxControlimplementations instead ofControlValueAccessor. -
Angular post-processing scripts: Remove the injection of
NG_VALUE_ACCESSORproviders,writeValue,registerOnChange,registerOnTouched,setDisabledState, andpropagateChangemethods. -
form-components.tsutility (packages/components/src/utils/form-components.ts): ThehandleFrameworkEventAngularfunction currently callspropagateChangeandwriteValue— this needs to be adapted or removed for Angular since Signal Forms handle value propagation through themodel()signal automatically. -
Usage with
FormFielddirective: 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_ACCESSORis registered, CVA works as before, Signal Forms don't exist so no conflict. - On Angular ≥ 21:
NG_VALUE_ACCESSORis not registered, theFormFielddirective 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_ACCESSORconditional provider entirely. - Remove
writeValue,registerOnChange,registerOnTouched,setDisabledState,propagateChange. - Add explicit
implements FormValueControl<T>/implements FormCheckboxControlwith 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
Labels
Type
Projects
Status