1

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)
1
  • I tried to use the change detector to force the UI to update (using both the markForCheck and detectChanges methods) and neither one updates the UI. Commented Jul 22, 2021 at 15:42

5 Answers 5

3
+150

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.

Sign up to request clarification or add additional context in comments.

Comments

1

I think the problem is that you are only communicating up when checking boxes... So your children each talk to the parent, but the parent doesn't communicate to the other child what happened. You could communicate the current state to the children.

But here's a quick and dirty:

By using your functions on click you make sure the active and inactive values are updated:

<k-checkmark-radio 
    formControlName="status" 
    groupName="status" 
    valueName="Active" 
    class="mr-3" 
    label="Active"
    (click)="makeActive()">
</k-checkmark-radio>
<k-checkmark-radio 
    formControlName="status" 
    groupName="status" 
    valueName="Inactive" 
    label="Inactive"
    (click)="makeInactive()">
</k-checkmark-radio>

I will try and get a prettier solution tomorrow, but meanwhile this might help.

Comments

1

The issue here is that in case of reactive forms Angular doesn't sync view between controls. For template driven form it would be done automatically because of ngOnChanges.

You can force that synchronization by calling setValue/patchValue on underlying FormControl after onChange call. The difference is that onChange doesn't sync view

control.setValue(control._pendingValue, { emitModelToViewChange: false });
                                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

In order to access FormControl inside ControlValueAccessor you can make use of Angular Injector:

checkmark-radio.component.ts

import { NgControl } from '@angular/forms';

...
constructor(private inj: Injector, ...) { }


onInputChange(val: any){ 
  ...
  this.onChange(val);
  this.inj.get(NgControl).control.setValue(val); <=== this one
}

Forked Stackblitz

Comments

1

The Problem

From your line constructor( private _cd: ChangeDetectorRef) { }, It is clear you understand what the problem actually is, Change detection is not being triggered on the custom components.

There is only one thing to note, consider below flow

  1. Trigger detection on form control
  <MyForm>
   <RadioGroup>
     <Radio></Radio>
     <Radio></Radio> <!-- Trigger change detection here -->
     <Radio></Radio>
   <RadioGroup>
  </MyForm>

  1. Change is propagated upwards
  <MyForm>
   <RadioGroup> <!-- Trigger change detection here -->
     <Radio></Radio>
     <Radio></Radio> 
     <Radio></Radio>
   <RadioGroup>
  </MyForm>

  1. Change is propagated upwards
  <MyForm> <!-- Trigger change detection here -->
   <RadioGroup> 
     <Radio></Radio>
     <Radio></Radio> 
     <Radio></Radio>
   <RadioGroup>
  </MyForm>

  1. Overally below components have not detected this change
  <MyForm> 
   <RadioGroup> 
     <Radio></Radio><!-- No Change detection here -->
     <Radio></Radio> 
     <Radio></Radio><!-- No Change detection here -->
   <RadioGroup>
  </MyForm>

So basically the other form controls are not updated as they are not aware of the new changes

Solution

The solution would be to force a value change in the parent. That way all the child inputs will be updated

A simple solution is to simply watch for valueChanges and update the control value

In the ngOnInit of your AppComponent add below lines

    this.form.get("status").valueChanges.subscribe({
      next: (y) => {
        this.form.get("status").setValue(y, {emitEvent: false})
      }
    })

NB: Do not forget emitEvent: false This avoids Maximum call stack exceeded error

See this forked demo

Comments

0

Here another way to do so

import {Component, forwardRef, Input} from '@angular/core';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from "@angular/forms";
import {generateUniqueId} from "../CUtils/c-utils"

@Component({
  selector: 'k-checkmark-radio',
  providers:[
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CRadioElement),
      multi: true
    }
  ],
  template: `
    <div class="k-radio">
      <input
          [id]="inputId"
          [checked]="checked"
          [value]="value"
          [name]="name"
          [disabled]="disabled"
          [required]="required"
          [attr.aria-describedby]="describedBy"
          [attr.aria-labelledby]="labelledBy"
          class="k-radio__input"
          type="radio"
          (change)="onInputChange()"
      />
      <label
          [htmlFor]="inputId"
          class="k-radio__label"
      >
        <ng-content/>
      </label>
    </div>
  `,
})
export class CRadioElement implements ControlValueAccessor {

  inputId: string = 'k-radio-' + generateUniqueId()
  checked = false

  @Input() name: string = ''
  @Input() value: string | number | boolean | null = null
  @Input() required: boolean = false
  @Input() describedBy: string | null = null
  @Input() disabled: boolean = false
  @Input() labelledBy :string|null = null


  onChange: any = () => { };
  onTouched: any = () => { };

  registerOnChange(fn: any): void {
    this.onChange = fn
  }

  registerOnTouched(fn: any): void {
    this.onTouched = fn
  }

  writeValue(value: any): void {
    this.checked = this.value !== null && value === this.value
  }

  setDisabledState(isDisabled: boolean) {
    this.disabled = isDisabled
  }

  onInputChange() {
    this.onChange(this.value);
    this.onTouched();
  }

}

you just need to custom you css ;) let me know on the comments if you want to see a whole example

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.