Cva

Reactive wrapper around Angular's Control Value Accessor (CVA) pattern. Provides signals for form control state (value, touched, disabled, invalid, etc.) and integrates seamlessly with both template-driven and reactive forms.

Signal Forms migration

This utility is particularly useful if you haven't migrated to Signal Forms yet and want to make building custom controls easier for template-driven or reactive forms. If you're already using Signal Forms, you may not need this utility.

Usage

Unlike traditional Control Value Accessor implementations, you don't need to implement any interfaces or manually inject NgControl. The utility automatically detects and integrates with Angular's form system, making your component compatible with plain value models, template-driven forms using ngModel, and reactive forms using formControl or formControlName.

angular-ts
import { Component, computed, model } from '@angular/core';
import { cva } from '@signality/core';

@Component({
  selector: 'app-currency-input',
  template: `
    <input
      type="text"
      [value]="displayValue()"
      [required]="cva.required()"
      (input)="handleInput($any($event.target).value)"
      (blur)="cva.touched.set(true)"
    />
  `,
})
export class CurrencyInput {
  readonly value = model<number>(0);
  readonly cva = cva({ value: this.value }); 

  readonly displayValue = computed(() => {
    return this.value()
      .toFixed(2)
      .replace(/\B(?=(\d{3})+(?!\d))/g, ','); // Shows "1,234.56"
  });

  handleInput(input: string) {
    const num = parseFloat(input.replace(/[^0-9.]/g, ''));
    if (!isNaN(num)) {
      this.value.set(num);
    }
  }
}

Parameters

ParameterTypeDescription
optionsCvaOptions<T>Configuration options (see Options below)

Options

The CvaOptions extends WithInjector:

OptionTypeDefaultDescription
valueWritableSignal<T>-Required. The value signal for the form control
touchedWritableSignal<boolean>signal(false)Custom touched state signal
disabledWritableSignal<boolean>signal(false)Custom disabled state signal
requiredWritableSignal<boolean>signal(false)Custom required state signal
invalidWritableSignal<boolean>signal(false)Custom invalid state signal
pendingWritableSignal<boolean>signal(false)Custom pending state signal
dirtyWritableSignal<boolean>signal(false)Custom dirty state signal
errorsWritableSignal<ValidationErrors | null>signal(null)Custom errors signal
injectorInjector-Optional injector for DI context

Return Value

The cva() function returns a CvaRef object:

PropertyTypeDescription
valueWritableSignal<T>The value signal (same as provided)
touchedWritableSignal<boolean>Whether the control has been touched
disabledSignal<boolean>Whether the control is disabled
requiredSignal<boolean>Whether the control is required
invalidSignal<boolean>Whether the control is invalid
pendingSignal<boolean>Whether the control is pending validation
dirtySignal<boolean>Whether the control value has changed
errorsSignal<ValidationErrors | null>Validation errors or null
reset() => voidReset the form control state

Examples

Currency input

angular-ts
import { Component, computed, model } from '@angular/core';
import { cva } from '@signality/core';

@Component({
  selector: 'app-currency-input',
  template: `
    <input
      type="text"
      [value]="displayValue()"
      [required]="cva.required()"
      [disabled]="cva.disabled()"
      (input)="handleInput($any($event.target).value)"
      (blur)="cva.touched.set(true)"
    />
    @if (cva.invalid() && cva.touched()) {
      <div class="error">
        @if (cva.errors()?.['required']) {
          <span>This field is required</span>
        }
      </div>
    }
  `,
})
export class CurrencyInput {
  readonly value = model<number>(0);
  readonly cva = cva({ value: this.value });

  readonly displayValue = computed(() => {
    return this.value()
      .toFixed(2)
      .replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  });

  handleInput(input: string) {
    const num = parseFloat(input.replace(/[^0-9.]/g, ''));
    if (!isNaN(num)) {
      this.value.set(num);
    }
  }
}

Usage with reactive forms:

angular-ts
import { Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';

@Component({
  template: `
    <form [formGroup]="form">
      <app-currency-input formControlName="price"/>
    </form>
  `,
  imports: [ReactiveFormsModule, CurrencyInput],
})
export class ProductForm {
  readonly form = new FormGroup({
    price: new FormControl(0),
  });
}

Custom checkbox

angular-ts
import { Component, model } from '@angular/core';
import { cva } from '@signality/core';

@Component({
  selector: 'app-custom-checkbox',
  template: `
    <label [class.disabled]="cva.disabled()">
      <input
        type="checkbox"
        [checked]="value()"
        [disabled]="cva.disabled()"
        (change)="value.set($any($event.target).checked)"
        (blur)="cva.touched.set(true)"
      />
      <span>{{ label() }}</span>
    </label>
  `,
})
export class CustomCheckbox {
  readonly checked = model(false);
  readonly label = input('Accept terms');
  readonly cva = cva({ value: this.checked });
}

Usage with template-driven forms:

angular-ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  template: `
    <form>
      <app-custom-checkbox [(ngModel)]="accepted" name="terms"/>
    </form>
  `,
  imports: [FormsModule, CustomCheckbox],
})
export class SignupForm {
  accepted = false;
}

Integration with Angular Forms

The cva() utility automatically integrates with Angular's form system:

  • Reactive Forms: works with FormControl, FormControlName, and FormGroup
  • Template-Driven Forms: works with NgModel
  • Validators: automatically syncs with Validators.required
  • State: automatically syncs touched, disabled, invalid, pending, dirty, and errors states

Type Definitions

typescript
interface CvaRef<T> {
  readonly value: WritableSignal<T>;
  readonly touched: WritableSignal<boolean>;
  readonly disabled: Signal<boolean>;
  readonly required: Signal<boolean>;
  readonly invalid: Signal<boolean>;
  readonly pending: Signal<boolean>;
  readonly dirty: Signal<boolean>;
  readonly errors: Signal<ValidationErrors | null>;
  readonly reset: () => void;
}

type CvaOptions<T> = Omit<Partial<MakeWritable<CvaRef<T>>>, 'value'> &
  Pick<CvaRef<T>, 'value'> &
  WithInjector;

function cva<T>(options: CvaOptions<T>): CvaRef<T>;
Edit this page on GitHub Last updated: Mar 19, 2026, 23:28:23