Skip to content

Instantly share code, notes, and snippets.

@vs-borodin
Last active December 21, 2025 18:37
Show Gist options
  • Select an option

  • Save vs-borodin/fdf59fc9313e1aaf7447b4d8399b4cd2 to your computer and use it in GitHub Desktop.

Select an option

Save vs-borodin/fdf59fc9313e1aaf7447b4d8399b4cd2 to your computer and use it in GitHub Desktop.
injectCva
import {
afterNextRender,
type AfterRenderRef,
assertInInjectionContext,
assertNotInReactiveContext,
type CreateEffectOptions,
effect,
type EffectCleanupRegisterFn,
type EffectRef,
inject,
INJECTOR,
type Injector,
runInInjectionContext,
type Signal,
signal,
untracked,
type WritableSignal,
} from '@angular/core';
import {
NgControl,
NgModel,
RequiredValidator,
type ValidationErrors,
Validators,
} from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { EMPTY, map, startWith, switchMap, timer } from 'rxjs';
export function injectCva<T>(options: CvaOptions<T>): CvaRef<T> {
if (ngDevMode && !options.injector) {
assertInInjectionContext(injectCva);
}
const injector = options.injector || inject(INJECTOR);
return runInInjectionContext(injector, () => {
const ngControl = inject(NgControl, { self: true, optional: true });
const ngModelRequired = inject(RequiredValidator, { self: true, optional: true });
const {
value,
touched = signal(false, { equal: () => false }),
disabled = signal(false),
required = signal(false),
invalid = signal(false),
pending = signal(false),
dirty = signal(false),
errors = signal(null),
} = options;
const cva = {
value,
touched,
disabled,
required,
invalid,
pending,
dirty,
errors,
};
if (!ngControl) {
return cva;
}
let touchedFn: () => void;
let updateFn: (v: T) => void;
let scheduledModelUpdate: AfterRenderRef | null = null;
const runModelUpdate = (fn: () => void) => {
scheduledModelUpdate?.destroy();
fn();
scheduledModelUpdate = afterNextRender(
() => {
scheduledModelUpdate = null;
},
{ injector }
);
};
watch(touched, isTouched => {
if (isTouched) {
touchedFn?.();
}
});
watch(value, v => {
if (scheduledModelUpdate) {
scheduledModelUpdate.destroy();
scheduledModelUpdate = null;
} else {
updateFn?.(v);
}
});
// the control instance isn’t available immediately inside FormControl or FormControlName,
// because they depend on [inputs]. That’s why we schedule the subscription asynchronously.
timer(0)
.pipe(
switchMap(() => {
const { control } = ngControl;
if (!control) {
return EMPTY;
}
return control.events.pipe(
startWith(null),
map(() => control)
);
}),
takeUntilDestroyed()
)
.subscribe(control => {
required.set(
control.hasValidator(Validators.required) ||
// cannot compare references because `RequiredValidator` wraps `requiredValidator` fn
// (https://github.com/angular/angular/blob/19.1.0/packages/forms/src/directives/validators.ts#L398)
!!ngModelRequired?.required
);
touched.set(control.touched);
invalid.set(control.invalid);
pending.set(control.pending);
errors.set(control.errors);
dirty.set(control.dirty);
});
ngControl.valueAccessor = {
writeValue: (rawValue: T | null) => {
runModelUpdate(() => {
// phantom pain fix (https://github.com/angular/angular/issues/14988)
const modelValue = ngControl instanceof NgModel ? ngControl.model : rawValue;
value.set(modelValue);
});
},
registerOnChange: fn => (updateFn = fn),
registerOnTouched: fn => (touchedFn = fn),
setDisabledState: isDisabled => disabled?.set(isDisabled),
};
return cva;
});
}
export interface CvaRef<T> {
readonly value: WritableSignal<T>;
readonly touched: WritableSignal<boolean>;
readonly disabled: Signal<boolean>;
readonly invalid: Signal<boolean>;
readonly required: Signal<boolean>;
readonly pending: Signal<boolean>;
readonly dirty: Signal<boolean>;
readonly errors: Signal<ValidationErrors | null>;
}
export interface CvaOptions<T>
extends Omit<Partial<MakeWritable<CvaRef<T>>>, 'value'>,
Pick<CvaRef<T>, 'value'> {
readonly injector?: Injector;
}
type MakeWritable<T extends object> = {
[K in keyof T]: T[K] extends Signal<infer U> ? WritableSignal<U> : never;
};
// Unlike the built-in `effect` function, `watch` responds to the fact of state change,
// enabling event-like orchestration of side effects.
// if you want, just replace it with `toObservable` and `skip(1)` operator 🙃
function watch<V>(
source: Signal<V>,
fn: (value: V, onCleanup: EffectCleanupRegisterFn) => void,
options?: CreateEffectOptions
): EffectRef {
if (ngDevMode) {
assertNotInReactiveContext(watch);
}
if (ngDevMode && !options?.injector) {
assertInInjectionContext(watch);
}
let wasCalled = false;
return effect(onCleanup => {
execute: {
const dep = source();
if (!wasCalled) {
wasCalled = true;
break execute;
}
untracked(() => fn(dep, onCleanup));
}
}, options);
}
@vs-borodin
Copy link
Author

Here’s an example from the Signal Forms docs, adapted for using injectCva
(remember — this is relevant if you still don’t plan to migrate to Signal Forms, and just want to make your life easier when building custom controls for template‑driven or reactive forms):

import { ChangeDetectionStrategy, Component, computed, model } from '@angular/core';
import { injectCva } from './inject-cva';

@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)"
    />
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class CurrencyInput {
  value = model<number>(0);
  cva = injectCva({ value: this.value }); // <-- inject it

  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);
    }
  }
}

From a usage perspective:

<form [formGroup]="form">
  <app-currency-input formControlName="currency"/>
</form>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment