Reputation: 227
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
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
Reputation: 56600
Key Considerations:
@if
is necessary, else you can discard that if condition in the htmlThe control is a input property so it can be updated in the future, so need to take this into account.
Since the signal input change I use effect
to validate when the control gets updated
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.
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
Finally, I add a cleanup block, since subscriptions should not live after the component is destroyed.
Since we are setting the value of a signal in the effect, I use allowSignalWrites: true
to allow this to be done!
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);
Upvotes: 1
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 FormControl
s and FormGroup
s 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