angularangular-reactive-formsangular-changedetectionangular-signals

toSignal on observables inside signal


With the recent Angular update (we are using v. 17) we started to use Angular signals in our application. There is one problem we are stumpling accross, that we are not sure how to handle properly.

We have components that receive a FormControl as @Input. We adjusted the code to the new Input-Signals

public readonly control = input<FormControl | null>(null)

Now we would like to have the valueChanges or statusChanges available as a signal, but we don't know how.

This is not reactive:

public readonly value = toSignal(this.control().valueChanges);

This is not allowed:

public readonly value = computed(() => toSignal(this.control().valueChanges));

Having the value as a signal could help to use it in more complex computed ways. E.g.

public readonly complexCondition = computed(() =>  this.value() == // some complex checks

and then use the signal in the template.

Without that, we would need a getter or a method which would not work with OnPush-Strategy (I think?) or would be executed every cycle, causing unnecessary calls because values might just stay the same.

Setting the signal manually from an effect is also prevented. We could put it into an effect which sets a basic variable maybe like this:

effect(() => {
    this.control()?.valueChanges.subscribe(val => {
    this.complexCondition =  this.value() == // some complex checks
    })

});

but that does seem a bit hacky and I think also would not work with OnPush-Strategy

Question: How to structure such cases?

I tried to find anything in the Angular docs or blogs, but I could find a similar problem. I think I might be on the wrong track here, but looking forward to see the proper way to do this.

EDIT

I continued reading into the topic and I think what actually would solve my problem is that Angular forms provide signals. Or we would have to wrap the formControl in our own class and provide own signal logic, but without the use of toSignal (handy for valueChanges and statusChanges) since this cannot be used outside of injection contexts.

There is this open feature request: https://github.com/angular/angular/issues/53485

As far as I read, this is something the Angular team is working on. Until then one could use this package: https://github.com/timdeschryver/ng-signal-forms

This is not an option for us, cause switching to this package would mean a lot of changes. But maybe it helps somebody else.


Solution

  • Key Considerations:

    1. The value of control will change and the child should be adapted to update of control state
    2. There will always be a form control defined, if you still want to use null as initial value, then the @if is necessary, else you can discard that if condition in the html

    The control is a input property so it can be updated in the future, so need to take this into account.

    1. Since the signal input change I use effect to validate when the control gets updated

    2. Then I subscribe to the signal control's valueChanges observable, but make sure to store it in an subscription object, why I do this is because when the control changes, the previous value changes subscription is useless, so we need to clean it up, I use a if condition and an unsubscribe.

    3. When the value changes of the control I create a new signal to store the control value and set the value inside the value changes

    4. Finally, I add a cleanup block, since subscriptions should not live after the component is destroyed.

    5. Since we are setting the value of a signal in the effect, I use allowSignalWrites: true to allow this to be done!

    6. I use a computed signal to perform any complex calculations needed, here it's just a toggle of label (valueFromComplexCondition)

    Full Code

    Child

    import { Component, input, effect, computed, signal } from '@angular/core';
    import { ReactiveFormsModule, FormControl } from '@angular/forms';
    import { Subscription } from 'rxjs';
    
    @Component({
      selector: 'app-test',
      standalone: true,
      imports: [ReactiveFormsModule],
      template: `
      @if(control(); as controlObj) {
        <input [formControl]="controlObj"/>
        <br/>
        valueFromComplexCondition: {{valueFromComplexCondition()}}
      }
      `,
      styleUrl: './test.component.css',
    })
    export class TestComponent {
      public readonly control = input<FormControl>(new FormControl());
      private subscription: Subscription = new Subscription();
      valueFromComplexCondition = computed(() =>
        this.value() === 'hello!' ? 'Correct' : 'incorrect'
      );
      private value = signal(this.control().value);
    
      constructor() {
        effect(
          (onCleanup: Function) => {
            if (this.subscription) {
              this.subscription.unsubscribe();
            }
            const control = this.control();
            if (control) {
              this.value.set(control.value);
              this.subscription = control.valueChanges.subscribe((val) => {
                console.log(val);
                this.value.set(val);
              });
            }
    
            onCleanup(() => {
              this.subscription.unsubscribe();
            });
          },
          {
            allowSignalWrites: true,
          }
        );
      }
    }
    

    Parent

    import { Component } from '@angular/core';
    import { bootstrapApplication } from '@angular/platform-browser';
    import 'zone.js';
    import { TestComponent } from './app/test/test.component';
    import { FormControl } from '@angular/forms';
    
    @Component({
      selector: 'app-root',
      standalone: true,
      imports: [TestComponent],
      template: `
      
        <app-test [control]="!switcher ? formControlA : formControlB"/>
        <button (click)="switcher = !switcher">toggle controls {{switcher ? "formControlB" : "formControlA"}}</button>
        <br/>
        formControlA: {{formControlA.value}}
        <br/>
        formControlB: {{formControlB.value}}
      `,
    })
    export class App {
      name = 'Angular';
      switcher = false;
      formControlA = new FormControl('hello!');
      formControlB = new FormControl('hello world!');
    }
    
    bootstrapApplication(App);
    

    Stackblitz Demo