Julia
Julia

Reputation: 227

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.

Upvotes: 6

Views: 5897

Answers (3)

Paul EDANGE
Paul EDANGE

Reputation: 220

Without using the effect function,
I think you could use the combination of toSignal and toObservable like this:

  public control: InputSignal<FormControl<T>> = input<FormControl<T>>();
  public readonly value: Signal<T> = toSignal(
    merge(
      // Get the initial value when 'control' changes
      toObservable(this.control).pipe(map((ctl: FormControl<T>) => ctl.value)),
      // Get the new value when 'control.value' changes
      toObservable(this.control).pipe(mergeMap((ctl: FormControl<T>) => ctl.valueChanges))
    )
  );

You could define a utility function to do this.

class SignalTransformerUtils {
  public static fromControlToValue = <T>(controlSignal: Signal<FormControl<T>>): Signal<T> => {
    return toSignal(
      merge(
        // Get the initial value when 'control' changes
        toObservable(controlSignal).pipe(map((ctl: FormControl<T>) => ctl.value)),
        // Get the new value when 'control.value' changes
        toObservable(controlSignal).pipe(mergeMap((ctl: FormControl<T>) => ctl.valueChanges))
      )
    );
  };
}

// ...

  public control: InputSignal<FormControl<T>> = input<FormControl<T>>();
  public readonly value: Signal<T> = SignalTransformerUtil.fromControlToValue(this.control);

Upvotes: 2

Naren Murali
Naren Murali

Reputation: 56600

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

Upvotes: 1

Krzysztof Michalak
Krzysztof Michalak

Reputation: 1

regarding the specific problem of extracting value from a FormControl, I've written a reusable function based on Naren Murali's answer, which you can use on variable initialization. It uses getRawValue(), so both FormControls and FormGroups can be passed as arguments.

The function:

const computedFromControl = <T, S = T>(
    control: () => { getRawValue(): T; valueChanges: Observable<unknown> },
    computation = (value: T) => value as unknown as S,
): Signal<S> => {
    let valueChangesSubscription: Subscription | undefined

    const value = signal<T | null>(null)

    effect(
        onCleanup => {
            valueChangesSubscription?.unsubscribe()

            value.set(control().getRawValue())
            valueChangesSubscription = control().valueChanges.subscribe(() => value.set(control().getRawValue()))

            onCleanup(() => valueChangesSubscription?.unsubscribe())
        },
        { allowSignalWrites: true },
    )

    return computed(() => computation(value() ?? control().getRawValue()))
}

Usage:

@Component({
    selector: 'app-test',
    template: `<div>{{ complexCondition() }}</div>`,
})
export class AppTestComponent {
    public readonly control = input.required<FormControl<boolean>>()

    public readonly complexCondition = computedFromControl(
        this.control,
        value => value ? 'value is true' : 'value is false',
    )
}

Upvotes: 0

Related Questions