skip to content

An Angular color-picker ControlValueAccessor never shows its required error after blur, ignores form.disable(), and shows stale colours after patchValue; what is broken?

level: seniorimportance: should knowfreq 42%

answer

  1. three symptoms, three methods
  2. who marks the control touched
  3. an optional method is silently skipped
  4. OnPush by default since v22

basics

~20 s

Each 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 s

The 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 lines
ts
import {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

for a junior

Know that a custom control must call its onTouched callback itself and implement setDisabledState, or touched errors and disable() will not work.

for a middle

Map each symptom to its accessor method and explain when setDisabledState is called since Angular 15.

for a senior

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.

for a principal

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