An Angular color-picker ControlValueAccessor never shows its required error after blur, ignores form.disable(), and shows stale colours after patchValue; what is broken?
answer
- three symptoms, three methods
- who marks the control touched
- an optional method is silently skipped
- OnPush by default since v22
basics
~20 sEach symptom is one accessor duty: onTouched is never called when focus leaves the picker, setDisabledState is missing or unrendered, and writeValue sets a plain field the default OnPush view never re-reads. Fix all three, holding displayed state in signals.
solid answer
~40 sThe three symptoms are three separate accessor bugs. **Touched:** only the callback from `registerOnTouched` marks the control touched, and a picker made of buttons and a popup often never calls it, or calls it on an element the user never blurs. Call it when focus leaves the whole widget. **Disabled:** `setDisabledState` is optional, so if it is missing Angular silently skips it; since v15 it is called on attach and on every `disable()`/`enable()`, and the component must actually disable its buttons. **Stale colour:** since v22 a component without `changeDetection` is `OnPush`, and a `writeValue` call from the forms API does not mark the view dirty, so a plain field assignment never renders. Store the value in a `signal()` or call `markForCheck()`. Also check that `writeValue` never calls `onChange`.
code
ts · 55 linesimport {Component, ElementRef, forwardRef, inject, signal} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
@Component({
selector: 'app-color-picker',
// no changeDetection: OnPush by default since v22
template: `
<button type="button" [disabled]="disabled()" (click)="open.set(!open())">
{{ color() ?? 'Pick a colour' }}
</button>
@if (open()) {
@for (c of palette; track c) {
<button type="button" [disabled]="disabled()" (click)="pick(c)">{{ c }}</button>
}
}
`,
host: {'(focusout)': 'onFocusOut($event)'},
providers: [
{provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => ColorPicker), multi: true},
],
})
export class ColorPicker implements ControlValueAccessor {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
protected readonly palette = ['#d32f2f', '#388e3c', '#1976d2'];
protected readonly color = signal<string | null>(null);
protected readonly disabled = signal(false);
protected readonly open = signal(false);
private onChange: (value: string | null) => void = () => {};
private onTouched: () => void = () => {};
writeValue(value: string | null): void {
this.color.set(value); // a signal write refreshes the OnPush view; no onChange here
}
registerOnChange(fn: (value: string | null) => void): void {
this.onChange = fn;
}
registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled.set(isDisabled);
if (isDisabled) this.open.set(false);
}
protected pick(value: string): void {
this.color.set(value);
this.open.set(false);
this.onChange(value);
}
// touched when focus leaves the whole widget, not when it moves between its buttons
protected onFocusOut(event: FocusEvent): void {
if (!this.host.nativeElement.contains(event.relatedTarget as Node | null)) {
this.onTouched();
}
}
}go deeper
Know that a custom control must call its onTouched callback itself and implement setDisabledState, or touched errors and disable() will not work.
Map each symptom to its accessor method and explain when setDisabledState is called since Angular 15.
Diagnose the OnPush-by-default stale view, the updateOn blur commit dependency on onTouched, and the dirty-flag bug from onChange in writeValue, then prove the fix with a model-level test.
Define a shared contract and test harness for every form widget in a component library, so touched, disabled and dirty behave the same across teams and survive framework upgrades.
## Reading the symptoms A `ControlValueAccessor` has four duties, and a bug in each shows up as a different symptom in the form. The colour picker here has three symptoms, and each maps to exactly one duty: | Symptom | Duty | Typical bug | |---|---|---| | required error never appears after the user leaves | report touched | `onTouched` never called, or called on the wrong element | | `form.disable()` has no visible effect | mirror disabled | `setDisabledState` missing, or it sets state the view never reads | | `patchValue({color: ...})` leaves the old swatch on screen | render model changes | `writeValue` writes a plain field under `OnPush` | ## Touched: nothing marks the control Templates usually hide errors until `control.touched` is true. The forms API never listens for blur on a custom component; the only way the control becomes touched is the accessor calling the function it stored from `registerOnTouched`. Common failures: - the function is stored but never called; - it is bound to `(blur)` on the host element, but the host of a composite widget is not focusable, so the host never blurs; - it is bound to one inner button, and the user leaves from the popup instead. The robust approach is to listen for `focusout` on the host and call `onTouched()` only when focus moves **outside** the widget (`event.relatedTarget` not contained in the host). `focusout` bubbles from the inner buttons, unlike `blur`. This callback does more than set a flag when the control uses `updateOn: 'blur'`: in that mode a user edit is only **committed** to the model when `onTouched` is called. An accessor that never calls it leaves such a control with its old value. ## Disabled: the optional method `setDisabledState?(isDisabled)` is optional in the interface, and the forms API calls it with optional chaining, so a component that does not implement it gets **no error**, just no effect. When it is implemented, it must change what the user can do: disable the buttons, close the popup. Timing to know: 1. Since Angular 15 it is called as soon as the accessor is attached, with `false` for an enabled control, and then on every `disable()` or `enable()`. 2. The pre-15 behaviour, calling it only when the control starts disabled, can be restored with `callSetDisabledState: 'whenDisabledForLegacyCode'` in `FormsModule.withConfig` or `ReactiveFormsModule.withConfig`. A component written for that behaviour may treat the first `false` call as unexpected. 3. With reactive forms, disable through the model (`control.disable()` or `{value, disabled: true}`), not a `[disabled]` template binding; Angular warns in development mode when it sees the attribute on a reactive directive. ## Stale values: OnPush meets writeValue Since v22, a component that does not set `changeDetection` is **`OnPush`**, and since v21 applications are zoneless by default. An `OnPush` view is refreshed when an input binding changes, when an event bound in its template or host bindings fires, when `markForCheck()` is called, or when a signal it read in its template changes. `writeValue` is none of those: the forms API calls it as a plain method, typically because a parent called `patchValue` after an HTTP response. Writing `this.color = value` updates the field and nothing renders it. Two fixes: - hold display state in a **`signal()`** and read it in the template; `this.color.set(value)` schedules that view for refresh (the current idiom); - or inject `ChangeDetectorRef` and call `markForCheck()` after assigning the field. Pinning the component to `ChangeDetectionStrategy.Eager` can mask the symptom when something else happens to trigger a check, but in a zoneless app a bare `writeValue` may schedule no check at all, so it is not a reliable fix; a signal write both marks the view and schedules the refresh. ## Two more bugs worth checking while you are there - **`onChange` inside `writeValue`.** Reporting a programmatic write back as a user edit marks the control **dirty** and emits `valueChanges` twice; with `ngModel` it also fires `ngModelChange`. It does not loop forever, because values that come from the view are not written back to it, which is why the bug survives review. - **`null` in `writeValue`.** After `reset()` on a nullable control, or on `ngModel`'s first pass, `writeValue(null)` arrives; the picker should show its empty state instead of throwing. ## A test that catches all of this Host the picker in a small test component with a `FormControl` and assert on the model, not only on the DOM: `touched` after focus leaves, the buttons disabled after `control.disable()`, the swatch updated after `setValue`, and `dirty` still `false` after a programmatic `setValue`.
- The control uses updateOn: 'blur' and the picked colour never reaches the model. Why?With `updateOn: 'blur'` a user edit is held as pending and only committed when the accessor calls its `onTouched` callback. If the picker never calls it, the pending value is never applied, so the model keeps the old colour even though the widget shows the new one.
- How would you unit-test that writeValue does not report a programmatic value as a user edit?Host the picker with a `FormControl`, call `control.setValue('#1976d2')`, let the fixture render, and assert that the swatch shows the colour while `control.dirty` is still `false` and a `valueChanges` spy fired once. Then click a palette button and assert `dirty` becomes `true`.
- Would switching the picker to ChangeDetectionStrategy.Eager be an acceptable fix for the stale colour?Only by accident. An `Eager` view is checked whenever a check reaches it, but in a zoneless app a bare `writeValue` may not schedule any check, and the widget opts out of the default everywhere it is used. A signal write marks the view and schedules the refresh, which fixes the cause.
The accessor is a receptionist between a manager (the FormControl) and visitors (the user). It must pass notes both ways, but it also has to tell the manager when a visitor leaves the building (touched) and lock the door when told to close (disabled). A receptionist who only passes notes gives the manager a building where nobody ever leaves and the door never locks.
saying these in an interview costs you the question
- Angular marks a custom control touched on its own when the host blurs
- If setDisabledState is missing Angular throws, so disabled state cannot silently fail
- writeValue can set a plain field because the forms API always triggers change detection
- Calling onChange in writeValue causes an infinite loop
- setDisabledState is only ever called when the control is actually disabled