Advanced Component Patterns — Control Value Accessor and Compound Components

ControlValueAccessor (CVA) is the interface that makes a component work natively with Angular’s form directives — formControlName, [(ngModel)], and the reactive form API. Without CVA, custom components cannot participate in Angular forms. With CVA, your StarRatingComponent works exactly like a native <input>: form validation, value access, and dirty/touched state all work automatically. The compound component pattern uses @ContentChild/@ContentChildren to query for child components projected via <ng-content>, enabling parent-child coordination like Angular Material’s <mat-tab-group>.

ControlValueAccessor Implementation

import { Component, forwardRef, signal, Input } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

// ── Star rating as a native form control ──────────────────────────────────
// Usage: <app-star-rating formControlName="rating" />
// OR:    <app-star-rating [(ngModel)]="post.rating" />

@Component({
  selector:   'app-star-rating',
  standalone:  true,
  providers: [
    {
      provide:     NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => StarRatingComponent),
      multi:       true,   // multiple CVA providers can coexist
    }
  ],
  template: `
    @for (star of stars; track star) {
      <button type="button"
              [class.filled]="star <= hoveredStar() || star <= value()"
              [disabled]="isDisabled()"
              (click)="select(star)"
              (mouseenter)="hoveredStar.set(star)"
              (mouseleave)="hoveredStar.set(0)"
              aria-label="Rate {{ star }} out of 5">
        ★
      </button>
    }
  `,
})
export class StarRatingComponent implements ControlValueAccessor {
  stars        = [1, 2, 3, 4, 5];
  value        = signal(0);
  hoveredStar  = signal(0);
  isDisabled   = signal(false);

  // Called by Angular when the parent form sets a value
  writeValue(value: number): void {
    this.value.set(value ?? 0);
  }

  // Register the callback Angular calls when the user changes the value
  registerOnChange(fn: (value: number) => void): void {
    this.onChange = fn;
  }

  // Register the callback Angular calls when the control is touched (blurred)
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }

  // Called when the form control is disabled/enabled
  setDisabledState(isDisabled: boolean): void {
    this.isDisabled.set(isDisabled);
  }

  select(star: number): void {
    if (this.isDisabled()) return;
    this.value.set(star);
    this.onChange(star);     // notify Angular forms of the new value
    this.onTouched();        // mark the control as touched
  }

  private onChange:  (value: number) => void = () => {};
  private onTouched: ()               => void = () => {};
}

// ── Compound component pattern — TabGroup + Tab ────────────────────────────
@Component({
  selector:   'app-tab',
  standalone:  true,
  template:   '<ng-content />',
})
export class TabComponent {
  @Input({ required: true }) label!: string;
  @Input() disabled = false;
}

@Component({
  selector:   'app-tab-group',
  standalone:  true,
  imports:    [NgFor, NgIf],
  template: `
    <div class="tab-header">
      @for (tab of tabs; track tab.label; let i = $index) {
        <button [class.active]="i === activeIndex()"
                [disabled]="tab.disabled"
                (click)="activeIndex.set(i)">
          {{ tab.label }}
        </button>
      }
    </div>
    <div class="tab-content">
      @for (tab of tabs; track tab.label; let i = $index) {
        <div [hidden]="i !== activeIndex()">
          <ng-container *ngComponentOutlet="getTabContent(tab)" />
        </div>
      }
    </div>
  `,
})
export class TabGroupComponent implements AfterContentInit {
  @ContentChildren(TabComponent) tabQueryList!: QueryList<TabComponent>;
  tabs: TabComponent[] = [];
  activeIndex = signal(0);

  ngAfterContentInit(): void {
    this.tabs = this.tabQueryList.toArray();
    // React to dynamically added/removed tabs
    this.tabQueryList.changes.subscribe(() => {
      this.tabs = this.tabQueryList.toArray();
    });
  }
}
Note: The four ControlValueAccessor methods form a two-way communication bridge. writeValue(): Angular → component (sets value programmatically). registerOnChange(fn): Angular registers its listener; component calls fn(newValue) when user changes value. registerOnTouched(fn): Angular registers its listener; component calls fn() when the control is “touched” (user interaction). setDisabledState(): Angular → component when the control is enabled/disabled. Implement all four for a complete, well-behaved form control.
Tip: The forwardRef() wrapper in the NG_VALUE_ACCESSOR provider is required because the class is referenced in its own decorator before it is fully defined (circular reference during class definition). Without forwardRef(), JavaScript would encounter undefined at the point where StarRatingComponent is referenced in useExisting. forwardRef() returns a function that resolves lazily, ensuring the class is fully defined by the time Angular accesses it.
Warning: Use @ContentChildren (not @ViewChildren) to query for child components projected via <ng-content>. @ViewChildren only queries components in the component’s own template. @ContentChildren queries the projected content that the parent inserts between the component tags. The results are available in ngAfterContentInit — not in ngOnInit — because projected content is initialised after the component’s own view.

Common Mistakes

Mistake 1 — Forgetting to call onChange() after value change (parent form never updates)

❌ Wrong — user clicks a star, internal signal updates, but this.onChange() is not called; the reactive form’s value never changes.

✅ Correct — always call this.onChange(newValue) after every user-initiated value change in the CVA component.

Mistake 2 — Using @ViewChildren instead of @ContentChildren for compound components (empty query)

❌ Wrong — @ViewChildren(TabComponent) on TabGroupComponent; projected tabs are content, not view children; query returns empty.

✅ Correct — use @ContentChildren(TabComponent) to query components projected via <ng-content>.

🧠 Test Yourself

A reactive form has rating: [0, [Validators.min(1)]]. A StarRatingComponent with CVA is bound via formControlName="rating". The user clicks star 3. What happens to the form control?