Search code examples
angularangular-reactive-forms

Angular Custom Radio Component 2-way Binding


I have created a custom radio component that just changes the style of our radio buttons to have checkmarks in them. I implemented ControlValueAccessor so that I could use the element with Reactive Forms, and have the component working properly when you click on the radio buttons in the UI. The problem I have is that when I try and set the value from my component rather than through a UI interaction (specifically trying to reset the form) the value changes properly on the reactive form, but the UI isn't updated.

Here is my custom radio control:

import { ChangeDetectorRef, Component, EventEmitter, forwardRef, Input, OnInit, Output } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'k-checkmark-radio',
  templateUrl: './checkmark-radio.component.html',
  styleUrls: ['./checkmark-radio.component.scss'],
  providers: [{
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CheckmarkRadioComponent),
      multi: true
  }]
})
export class CheckmarkRadioComponent implements OnInit, ControlValueAccessor {

  @Input() groupName: string = "";
  @Input() label: string = "";
  @Input() valueName: string = "";
  @Input() radioId!: string;

  public checked: boolean;
  public value!: string;
  constructor( private _cd: ChangeDetectorRef) { }

  onChange: any = () => {}
  onTouch: any = () => {}

  onInputChange(val: string){
    this.checked = val == this.valueName;
    this.onChange(val);
  }

  writeValue(value: string): void {

    this.value = value;
    this.checked = value == this.valueName;
    console.log(`${this.valueName} Writing Checkmark Value: `, value, this.checked);
    this._cd.markForCheck(); 
  }
  registerOnChange(fn: any): void {
    this.onChange = fn;
  }
  registerOnTouched(fn: any): void {
    this.onTouch = fn;
  }

  ngOnInit(): void {
  }

}

and here is the template for it:

<div class="form-check form-check-inline">
    <label>
        <input 
            type="radio" 
            [id]="groupName + '_' + valueName" 
            (ngModelChange)="onInputChange($event)" 
            [name]="groupName" 
            [value]="valueName" 
            [(ngModel)]="value">
        <span class="label-size">{{ label }}</span>
    </label>
</div>
<br /> Checked? {{ checked }}

I setup a working example of the problem here: https://stackblitz.com/edit/angular-ivy-23crge?file=src/app/checkmark-radio/checkmark-radio.component.html and you can recreate the problem by doing the following:

  1. click on the Inactive radio button (should show blue state properly)
  2. click on Reset button (both radios will be empty, but you will see the form shows Active correctly)

Solution

  • This was indeed confusing, but here is the answer. You should not deal with ngModel or ngModelChange inside your radio button control.

    There are several things that need to be done for this to work properly:

    1. Whenever the value changes from outside, set the checked property of the internal radio button to the proper value. This is done in writeChanges.

    2. Whenever the value changes from inside (by user click), inform the outside world that it had changed. This is done by listening to change and calling onChange.

    3. During initialization, make sure the checked property is set according to the value.

    Here's your stackblitz, cleaned and fixed.

    So to summarize, this is what I've done in the template:

    • Removed [(ngModel)] and (ngModelChange) bindings from the internal radio button.
    • Added (change)="onInputChange($event) to the radio button

    In the class:

    • Modified onInputChange to only call this.onChange(this.valueName) - this notifies the outside world of the change, nothing else needs to be done.
    • Modified writeValue to update the checked state of the internal radio button (by using a ViewChild), this is the inward direction, only the internal radio button needs to change and nothing else.

    Note: Since the radio button view child is not initialized on the first call to writeValue I also implemented AfterViewInit and I update the checked property there as well. Look at the stackblitz for clarification.