There’s a specific kind of late-project design request that every developer recognises. It sounds like nothing: “Could we just add a confirm step to the multiselect? So it only applies when you press Confirm?” That’s the request. A button. Turns out there is no such thing as just a button.

In one of the projects I worked on, we had exactly this requirement. A dropdown multiselect needed to support a draft state: users could select options, deselect them, and click around as much as they wanted, but nothing would be committed to the form until they hit “Confirm”. Clicking “Cancel” or closing the dropdown had to revert to the last confirmed value — and if you reopened the dropdown, it needed to remember the draft selection, not reset it.

The way I solved this initially was a wrapper component with a PrimeNG MultiSelect inside and some state logic around it. It worked, but we quickly needed to expose more and more of the MultiSelect API, and the component kept growing. Only later did I come across ControlValueAccessor. This post walks through both: the naive wrapper first, then a clean CVA implementation, and finally a directive-based alternative with its own trade-offs.

Why it’s not just a button

The standard PrimeNG p-multiSelect fires its value change immediately when a user clicks an item. Under the hood, that’s a ControlValueAccessor calling onChange(newValue) — Angular’s signal that the bound form control should update. The moment you add a confirm step, that signal needs to be held back. The form should not see the new value until the user explicitly approves it.

This has knock-on effects. The display and the form value are now two different things — the multiselect can show “A, B, C” while the form still holds “A, B”. Cancel has a specific meaning too: it isn’t just “close the dropdown”, it means “restore the display to whatever the form currently holds and discard the user’s pending selection”. And touched semantics change — with a confirm step, the control should be marked touched when the user makes an explicit decision, not on every checkbox click.

The mental model that unlocks all of this is treating the component as having two values at any given time: a draft (what’s shown in the open panel, not yet committed) and a confirmed value (what the form control actually holds). Think of it as a transaction: you load the current value, the user edits the draft, and then either commits (confirm) or rolls back (cancel).

First attempt: the component hierarchy

Before I knew about CVA, I solved this with a wrapper component: EventEmitters, local state, and a lot of [disabled] bindings.

@Component({
  selector: 'app-confirmable-multiselect',
  template: `
    <p-multiSelect
      [options]="options"
      [(ngModel)]="draft"
    />
    <button (click)="cancel()">Cancel</button>
    <button (click)="confirm()" [disabled]="!hasPendingChanges">Confirm</button>
  `
})
export class ConfirmableMultiSelectComponent {
  @Input() options: any[] = [];
  @Input() value: any[] = [];
  @Output() valueChange = new EventEmitter<any[]>();

  protected draft: any[] = [];

  ngOnChanges() {
    this.draft = [...this.value];
  }

  protected confirm() {
    this.value = [...this.draft];
    this.valueChange.emit(this.value);
  }

  protected cancel() {
    this.draft = [...this.value];
  }

  protected get hasPendingChanges(): boolean {
    return JSON.stringify(this.draft) !== JSON.stringify(this.value);
  }
}

This works. But it lives outside Angular’s forms system. You can’t bind [formControl] to it, form.reset() doesn’t know it exists, it has no concept of touched, and it won’t participate in FormGroup validation. As soon as you need it in more than one place, the boilerplate compounds. The problem isn’t the logic — the draft/confirmed distinction is exactly right. The problem is that we reinvented a wheel Angular already has.

ControlValueAccessor

ControlValueAccessor is the interface that allows a component to act as a form control — to be driven by [formControl], [(ngModel)], or formControlName. Every HTML <input> works with Angular forms because Angular ships built-in CVA implementations for them. PrimeNG’s p-multiSelect works the same way.

The interface has four methods:

interface ControlValueAccessor {
  // Angular → Component: "here is the current form value, show it"
  writeValue(obj: any): void;

  // Angular → Component: "here is the function to call when the value changes"
  registerOnChange(fn: any): void;

  // Angular → Component: "here is the function to call when the control is touched"
  registerOnTouched(fn: any): void;

  // Angular → Component: "the form control has been disabled/enabled"
  setDisabledState?(isDisabled: boolean): void;
}

The key insight is registerOnChange. Angular hands you a function, you store it, you call it when your component decides to propagate a new value. In a standard input that’s on every keystroke. In our confirmable multiselect, it’s only on confirm(). As it turns out, that’s the entire premise.

To register as a CVA, a component declares itself under NG_VALUE_ACCESSOR:

providers: [
  {
    provide: NG_VALUE_ACCESSOR,
    useExisting: forwardRef(() => YourComponent),
    multi: true,
  },
]

forwardRef is needed because the class reference isn’t available when the providers array is evaluated. multi: true means this token can have multiple providers — Angular’s own built-in accessors use the same token.

Component setup

First, let’s register our component as a value accessor:

@Component({
  selector: "app-confirmable-multiselect",
  standalone: true,
  imports: [CommonModule, ReactiveFormsModule, MultiSelectModule, ButtonModule],
  templateUrl: "./confirmable-multiselect.component.html",
  styleUrl: "./confirmable-multiselect.component.css",
  changeDetection: ChangeDetectionStrategy.OnPush,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => ConfirmableMultiSelectComponent),
      multi: true,
    },
  ],
})
export class ConfirmableMultiSelectComponent implements ControlValueAccessor {
  // ... implementation coming up
}

Managing state

We need to separate the UI state from the form state. The component tracks three things:

export class ConfirmableMultiSelectComponent implements ControlValueAccessor {
  // Binds to the internal PrimeNG component
  protected readonly displayControl = new FormControl<unknown[]>([], {
    nonNullable: true,
  });

  private draftValue: unknown[] = [];
  private confirmedValue: unknown[] = [];
  protected disabled = false;
  private panelOpen = false;

  private onChange: (value: unknown[]) => void = () => {};
  private onTouched: () => void = () => {};

  constructor() {
    this.displayControl.valueChanges.subscribe((value) => {
      if (!this.panelOpen) {
        // ignore programmatic writes — setDisplayValue uses emitEvent: false anyway
        return;
      }

      const safe = Array.isArray(value) ? (value as unknown[]) : [];
      this.draftValue = [...safe];
    });
  }
}

displayControl drives the inner p-multiSelect — it reflects what the user sees. draftValue shadows it while the panel is open. confirmedValue is the actual form value and only changes when the user confirms.

The subscription only updates draftValue when the panel is open, so programmatic writes (resetting the display on panel close) don’t bleed into the draft. Those go through setDisplayValue with { emitEvent: false } anyway, so valueChanges doesn’t fire for them.

Implementing CVA methods

writeValue(value: unknown): void {
  const next = Array.isArray(value) ? (value as unknown[]) : [];
  this.confirmedValue = [...next];

  if (!this.panelOpen) {
    this.draftValue = [...this.confirmedValue];
    this.setDisplayValue(this.confirmedValue);
  }
}

registerOnChange(fn: (value: unknown[]) => void): void {
  this.onChange = fn;
}

registerOnTouched(fn: () => void): void {
  this.onTouched = fn;
}

setDisabledState(isDisabled: boolean): void {
  this.disabled = isDisabled;
  isDisabled ? this.displayControl.disable() : this.displayControl.enable();
}

writeValue is called by Angular when the parent form value changes. We update confirmedValue immediately. If the panel is closed, we also sync draftValue and the display. If the panel is open, we leave the display alone so we don’t disrupt the user. setDisabledState uses the form control’s own disable()/enable() — Angular explicitly warns against mixing [disabled] with reactive form controls.

The transaction logic

This is the core of the confirmable behaviour:

protected confirm(): void {
  if (!this.hasPendingChanges) {
    this.onTouched();
    this.multiSelect?.hide();
    return;
  }

  // drop values referencing options removed since the draft was made
  const validDraft = this.getValidValues(this.draftValue);

  this.confirmedValue = [...validDraft];
  this.draftValue = [...this.confirmedValue];

  this.onChange([...this.confirmedValue]);
  this.onTouched();

  this.setDisplayValue(this.confirmedValue);
  this.multiSelect?.hide();
}

protected cancel(): void {
  this.draftValue = [...this.confirmedValue];
  this.setDisplayValue(this.confirmedValue);
  this.onTouched();
  this.multiSelect?.hide();
}

We only call this.onChange inside confirm(). This is the moment the form control actually receives a new value. getValidValues filters the draft against the current options list, so values referencing options that have since been removed can’t slip through.

Can we do better?

The logic above works, but the draft doesn’t persist between panel opens. If a user opens the dropdown, selects a few items, closes it without confirming, and reopens — they’d lose their draft selection. We can handle this with panel show/hide events.

protected handlePanelShow(): void {
  this.panelOpen = true;
  const next = this.draftValue.length ? this.draftValue : this.confirmedValue;
  this.setDisplayValue(next);
}

protected handlePanelHide(): void {
  this.panelOpen = false;
  this.setDisplayValue(this.confirmedValue);
}

On show, if there’s an existing draft, we restore it. On hide, we snap back to confirmedValue so the closed chip display is never misleading.

Let’s look at the helpers that glue this together:

private setDisplayValue(value: unknown[]): void {
  this.displayControl.setValue([...value], { emitEvent: false });
}

protected get hasPendingChanges(): boolean {
  if (this.disabled) {
    return false;
  }

  return !this.areSame(this.draftValue, this.confirmedValue);
}

private areSame(a: unknown[], b: unknown[]): boolean {
  if (a.length !== b.length) {
    return false;
  }

  const sortedA = [...a].sort();
  const sortedB = [...b].sort();

  return sortedA.every((value, index) => value === sortedB[index]);
}

private getValidValues(values: unknown[]): unknown[] {
  return values.filter((value) =>
    this.options.some((opt: any) => {
      const optValue = this.optionValue ? opt[this.optionValue] : opt;
      return optValue === value;
    }),
  );
}

We sort both arrays before comparing in areSame because the selection order can change depending on what the user deselects and reselects — ['B', 'A'] and ['A', 'B'] are the same selection and shouldn’t enable the Confirm button unnecessarily.

The template

Let’s put the template together. PrimeNG has a footer template slot — that’s where the confirm and cancel buttons go:

<p-multiSelect
  [options]="options"
  [optionLabel]="optionLabel"
  [optionValue]="optionValue"
  [placeholder]="placeholder"
  [filter]="filter"
  display="chip"
  [formControl]="displayControl"
  (onPanelShow)="handlePanelShow()"
  (onPanelHide)="handlePanelHide()"
>
  <ng-template pTemplate="footer">
    <div class="confirmable-actions">
      <button
        pButton
        type="button"
        label="Cancel"
        class="p-button-text"
        [disabled]="disabled"
        (click)="cancel()"
      ></button>
      <button
        pButton
        type="button"
        label="Confirm"
        [disabled]="disabled || !hasPendingChanges"
        (click)="confirm()"
      ></button>
    </div>
  </ng-template>
</p-multiSelect>

From the outside, this component is a first-class form citizen. [formControl], form.reset(), form.patchValue(), and disabled states all work exactly as expected.

The directive approach

The component has one trade-off: it only exposes what it explicitly @Input()s. PrimeNG’s p-multiSelect has around thirty inputs — styleClass, appendTo, virtualScroll, and many others. If a consumer needs any of those, you have to thread them through. An alternative is to implement the same behaviour as a directive sitting on the p-multiSelect element itself, leaving the full PrimeNG API accessible.

<p-multiSelect
  appConfirmable
  #cs="confirmable"
  [formControl]="citiesControl"
  [options]="cityOptions"
  optionLabel="label"
  optionValue="value"
  display="chip"
  appendTo="body"
  styleClass="w-full"
>
  <ng-template pTemplate="footer">
    <div class="confirmable-actions">
      <button pButton label="Cancel" class="p-button-text" (click)="cs.cancel()"></button>
      <button pButton label="Confirm" [disabled]="!cs.hasPendingChanges" (click)="cs.confirm()"></button>
    </div>
  </ng-template>
</p-multiSelect>

Every PrimeNG input works as normal. The directive is referenced as #cs="confirmable" via exportAs: 'confirmable' in the directive decorator — the same mechanism Angular uses for things like #f="ngForm". The confirm and cancel buttons live in PrimeNG’s footer template slot, just like in the component approach.

Intercepting the pipeline

Here’s the problem. When [formControl]="citiesControl" is processed, Angular calls registerOnChange and registerOnTouched on the multiselect, handing it the callbacks to fire when the value changes. By the time our directive’s ngAfterViewInit runs, that handshake is already done — Angular and PrimeNG are wired up, and we’re not in the loop.

To insert ourselves between them, we reach into the PrimeNG instance and replace those callbacks:

ngAfterViewInit(): void {
  const ms = this.multiSelect as any;

  this.originalOnModelChange = ms.onModelChange;
  this.originalOnModelTouched = ms.onModelTouched;

  ms.onModelChange = (val: unknown) => {
    const safe = Array.isArray(val) ? ([...val] as unknown[]) : [];
    if (this.panelOpen) {
      this.draftValue = safe;
      return;
    }
    this.confirmedValue = safe;
    this.originalOnModelChange(val);
  };

  ms.onModelTouched = () => {};
}

This is monkey-patching, which is the polite term. It works on PrimeNG 20 because onModelChange and onModelTouched are the actual property names used internally, but they are not part of PrimeNG’s public API. A version bump could rename them, and the directive would silently stop propagating values to the form — which is about the worst kind of failure you can have in a form control.

There’s one more thing ngAfterViewInit needs to handle: external writes. When form.reset() is called, Angular updates the multiselect via writeValue, which doesn’t go through our intercepted callback. We watch control.valueChanges instead to stay in sync:

this.ngControl.control?.valueChanges
  .pipe(takeUntilDestroyed(this.destroyRef))
  .subscribe((val) => {
    this.confirmedValue = Array.isArray(val) ? [...val] : [];
    if (!this.panelOpen) {
      this.draftValue = [...this.confirmedValue];
    }
  });

The panel show/hide subscriptions work the same as in the component, just using this.multiSelect.onPanelShow and onPanelHide directly instead of event bindings in a template.

Confirm and cancel

The directive doesn’t implement CVA, so it doesn’t have a this.onChange to call. Instead it calls originalOnModelChange — the function Angular originally handed to PrimeNG:

confirm(): void {
  if (!this.hasPendingChanges) {
    this.originalOnModelTouched();
    this.multiSelect.hide();
    return;
  }

  const validDraft = this.getValidValues(this.draftValue);
  this.confirmedValue = [...validDraft];
  this.draftValue = [...this.confirmedValue];

  this.originalOnModelChange([...this.confirmedValue]);
  this.originalOnModelTouched();
  this.multiSelect.hide();
}

cancel(): void {
  this.draftValue = [...this.confirmedValue];
  this.setMultiSelectDisplay(this.confirmedValue);
  this.originalOnModelTouched();
  this.multiSelect.hide();
}

The same limitation applies to updating the display. The component owned displayControl and could call setValue({ emitEvent: false }). The directive doesn’t own it, so it has to call into PrimeNG directly:

private setMultiSelectDisplay(value: unknown[]): void {
  this.multiSelect.writeControlValue([...value], (v: any) => {
    this.multiSelect.modelValue.set(v);
  });
}

writeControlValue is the method PrimeNG’s CVA implementation calls for writeValue, and modelValue is the Signal it uses internally for rendering. Neither is a public API, which reinforces the caveat from earlier — this approach lives and dies by PrimeNG’s internals staying stable.

Cleanup

On destroy, we restore the original callbacks. This matters if the directive is conditionally applied or the component is destroyed and recreated.

ngOnDestroy(): void {
  const ms = this.multiSelect as any;
  if (this.originalOnModelChange) ms.onModelChange = this.originalOnModelChange;
  if (this.originalOnModelTouched) ms.onModelTouched = this.originalOnModelTouched;
}

Which one to use?

The component is the one I’d default to on a new project. The CVA contract is standard Angular, the logic is self-contained, and you’re not depending on PrimeNG internals staying stable. The trade-off is that you lose direct access to anything not explicitly threaded through as an @Input().

The directive genuinely earns its added complexity when you need PrimeNG configuration the component doesn’t expose, and can’t or won’t modify the component to add it. That’s a real constraint in some projects, not a hypothetical one. If you go that route, pin your PrimeNG version, write tests that verify valueChanges fires on confirm and not on cancel, and annotate which internal properties you’re relying on and which version they were verified against.

Either way, the point of using CVA at all is that once you do, the confirmation logic becomes invisible to consumers. They use [formControl], form.reset(), and disabled states exactly as they always have. The confirmable behaviour is a detail of the control, not of the form that uses it. That’s the difference between the naive wrapper and both of these approaches — and, honestly, the most useful thing I got out of a feature request that was, in fairness, just a button.