Last active
December 21, 2025 18:37
-
-
Save vs-borodin/fdf59fc9313e1aaf7447b4d8399b4cd2 to your computer and use it in GitHub Desktop.
injectCva
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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):
From a usage perspective: