Skip to content

Instantly share code, notes, and snippets.

@cyrillbrito
Last active May 15, 2025 08:53
Show Gist options
  • Save cyrillbrito/f387212029bcc97287088297492c54d8 to your computer and use it in GitHub Desktop.
Save cyrillbrito/f387212029bcc97287088297492c54d8 to your computer and use it in GitHub Desktop.
@Directive({
standalone: true,
providers: [
{
provide: NG_VALUE_ACCESSOR,
multi: true,
useExisting: HostControlDirective,
},
],
})
export class HostControlDirective implements ControlValueAccessor {
control!: FormControl;
private injector = inject(Injector);
private subscription?: Subscription;
ngOnInit(): void {
const ngControl = this.injector.get(NgControl, null, { self: true, optional: true });
if (ngControl instanceof FormControlName) {
const group = this.injector.get(ControlContainer).control as UntypedFormGroup;
this.control = group.controls[ngControl.name!] as FormControl;
return;
}
if (ngControl instanceof FormControlDirective) {
this.control = ngControl.control;
return;
}
if (ngControl instanceof NgModel) {
this.subscription = ngControl.control.valueChanges.subscribe(newValue => {
// The viewToModelUpdate updates the directive and triggers the ngModelChange.
// So we want to called it when the value changes except when it comes from the parent (ngModel input).
// The `if` checks if the newValue is different from the value on the ngModel input or from the current value.
if (ngControl.model !== newValue || ngControl.viewModel !== newValue) {
ngControl.viewToModelUpdate(newValue);
}
});
this.control = ngControl.control;
return;
}
// Fallback
this.control = new FormControl();
}
writeValue(): void { }
registerOnChange(): void { }
registerOnTouched(): void { }
ngOnDestroy(): void {
this.subscription?.unsubscribe();
}
}
// Usage example
@Component({
selector: 'app-custom-input',
template: `<input [formControl]="hcd.control" />`,
standalone: true,
imports: [ReactiveFormsModule],
hostDirectives: [HostControlDirective],
})
export class CustomInputComponent {
hcd = inject(HostControlDirective);
}
@pookdeveloper
Copy link

Any solution for work with ngmodel?

@pookdeveloper
Copy link

@Dimonina why is memory leak? can you explain?

@Dimonina
Copy link

the subscription is created, but there's no unsubscription, so it will live forever even after your components are destroyed

@pookdeveloper
Copy link

But since it is associated with the form, it won't die automatically? How can you check to see if the variable stays alive? Thanks @Dimonina

@cyrillbrito
Copy link
Author

@pookdeveloper this solution does work with ngModel. As for the leak I am not sure how we would test it. But if indeed it is a leak we need to return the subscription to the caller and the caller most run the unsubscribe onDestroy. In the v16 we might be able to avoid this with the destroyRef

@pookdeveloper
Copy link

@cyrillbrito Ok thanks, this ca be done with directive ? Can you share an example ??

@Dimonina
Copy link

you can test the unsubscription by

ngControl.control.valueChanges.pipe(
finalize(() => console.log('will never be called'))
).subscribe(....)

As for the host control, I finally came to this approach: https://netbasal.com/forwarding-form-controls-to-custom-control-components-in-angular-701e8406cc55

@pookdeveloper
Copy link

pookdeveloper commented Apr 19, 2023

@cyrillbrito what do you think about this approach: I use NoopValueAccessorDirective by directive:
pd/ if there is any better approach feel free to tell me :)

import { Component, Input, OnDestroy } from '@angular/core';
import { injectNgControl, NoopValueAccessorDirective } from '../noop-value-accessor/noop-value-accessor.directive';
import { Subject } from 'rxjs';

@Component({
  selector: 'app-new-wrapper-form-control',
  templateUrl: './new-wrapper-form-control.component.html',
  styleUrls: ['./new-wrapper-form-control.component.scss'],
  hostDirectives: [NoopValueAccessorDirective]
})
export class NewWrapperFormControlComponent implements OnDestroy {
  //
  _control;
  _destroyed: Subject<any>;

  constructor() {
    this._control = injectNgControl(this._destroyed);
  }

  ngOnDestroy() {
    this._destroyed.next({});
    this._destroyed.complete();
  }
}
import {
  ControlValueAccessor, FormControlDirective, FormControlName, NG_VALUE_ACCESSOR, NgControl, NgModel
} from '@angular/forms';
import { Directive, inject } from '@angular/core';
import { Subject, takeUntil } from 'rxjs';

export function injectNgControl(_destroyed: Subject<any>): any {
  const ngControl = inject(NgControl, {self: true, optional: true});
  // if (!ngControl) throw new Error('...');
  if (
    ngControl instanceof FormControlDirective ||
    ngControl instanceof FormControlName ||
    ngControl instanceof NgModel
  ) {
    // The viewToModelUpdate updates the directive and triggers the ngModelChange.
    // So we want to called it when the value changes except when it comes from the parent (ngModel input).
    // The `if` checks if the newValue is different from the value on the ngModel input or from the current value.
    if (ngControl instanceof NgModel) {
      ngControl.control.valueChanges
        .pipe(takeUntil(_destroyed))
        .subscribe(newValue => {
          if (ngControl.model !== newValue || ngControl.viewModel !== newValue) {
            ngControl.viewToModelUpdate(newValue);
          }
        });
    }
    return ngControl;
  }
  // throw new Error('...');
}

@Directive({
  standalone: true,
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: NoopValueAccessorDirective,
    },
  ],
  selector: '[appNoopValueAccessor]'
})
export class NoopValueAccessorDirective implements ControlValueAccessor {
  writeValue(obj: any): void {
  }

  registerOnChange(fn: any): void {
  }

  registerOnTouched(fn: any): void {
  }

}

@cyrillbrito
Copy link
Author

@pookdeveloper I updated the code to take advantage of the hostDirectives and fixed the memory leak. I think it looks really clean now, and it works with ngModel, formControl and formControlName

@pookdeveloper
Copy link

Nice @cyrillbrito :)

@pookdeveloper
Copy link

@cyrillbrito nice work, only .. Inject flags are deprecated:
const ngControl = this.injector.get(NgControl, null, InjectFlags.Self + InjectFlags.Optional);
Now:
const ngControl = this.injector.get(NgControl, null, {self: true, optional: true});

@pookdeveloper
Copy link

@cyrillbrito I get and error with your example:
image

@cyrillbrito
Copy link
Author

@pookdeveloper Fixed the depredated flags, thanks. Maybe use are using NoopValueAccessorDirective together with my HostControlDirective and since both provide NG_VALUE_ACCESSOR it is causing problems. With my solution you don't need the NoopValueAccessorDirective since it is already incorporated

@pookdeveloper
Copy link

pookdeveloper commented Apr 28, 2023

@cyrillbrito your HostControlDirective is my NoopValueAccessorDirective i only use this name , but i hace the same code

@pookdeveloper
Copy link

The problem is that I have move the code in NgOninit to constructor.

@Xriuk
Copy link

Xriuk commented Oct 27, 2023

I had some problems with array values, the code above caused the changes to be triggered twice, so I changed the condition to this:

this.subscription = ngControl.control.valueChanges.subscribe(newValue => {
  // The viewToModelUpdate updates the directive and triggers the ngModelChange.
  // So we want to called it when the value changes except when it comes from the parent (ngModel input).
  // The `if` checks if the newValue is different from the value on the ngModel input or from the current value.
  if (ngControl.model !== newValue && ngControl.viewModel !== newValue) {
    ngControl.viewToModelUpdate(newValue);
  }
});

So that only when both models are different it triggers the update. Now it seems to work

@jacobfederer
Copy link

@cyrillbrito I like your solution! But how do you test it? I tried to provide a custom version of NgControl, but the self option of the Injector.get seems to prevent this. In my dependent component I'm unable to inject a working version of NgControl.

@cyrillbrito
Copy link
Author

@jacobfederer Did not really understand your question. What do you mean by "custom version of NgControl" ?
There is an example of how to use in the snippet.

@jacobfederer
Copy link

jacobfederer commented Apr 24, 2025

Yes, I just wanted to write a unit test for my component and wondered on how to inject an instance of NgControl in this case. But I found a solution for it. This seems to work:

let testFormControl: FormControl


 beforeEach(waitForAsync(() => {

   testFormControl = new FormControl(null);

   const formControlDirective = new FormControlDirective([], [], [
// At least one control value accessor is required
     {writeValue: () => null, registerOnChange: () => null, registerOnTouched: () => null}
   ], null, null);

   formControlDirective.form = testFormControl
   formControlDirective.name = "test"

   TestBed.configureTestingModule({
     imports: [ParentFormControlDirective, TextAreaComponent, NoopAnimationsModule],
   }).overrideComponent(NextTextAreaComponent, {
     
 // A regular provider does not work with the injector self-flag, you need to override it like this
     set: {
       providers: [
         {
           provide: NgControl,
           useValue: formControlDirective
         }
       ]
     }
   })
     .compileComponents();

 }));

@pookdeveloper
Copy link

Hi @cyrillbrito when I change the reference of a form group the formcontrols dosent update the reference

@vivekraj-kr
Copy link

Do you have the working example? With an example usage? Thanks in advance!

@pookdeveloper
Copy link

of course @vivekraj-kr as you can see in the image when I use the control with Control, it always binds me to the reference of the first form:

https://stackblitz.com/edit/stackblitz-starters-syppdkqc?file=src%2Fapp%2Fcustom-input-hostcontrol.component.ts

image

image

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