ngxtension/ngxtension-platform

Document usage of utilities to convert form values to signals

NateRadebaugh opened this issue · 12 comments

Signals are great but when I have a computed that relies on a form field value, there isn't a great way to trigger the re-compute.

I was thinking using signalSlice could provide form values from the formGroup:

formValues = signalSlice({
   initialState: this.formGroup.value,
   sources: [this.formGroup.valueChanges],
});

Is this supposed to work? Or is there a better way to set up a computed value that also uses a form value?

breadcrumbs = computed<BreadcrumbItem[]>(() => {
   // const subject = this.formGroup.controls.subject.value;
   const subject = this.formValues.subject();

   return [
      { label: "Home", to: "/" },
      {
        label: "App Announcement List",
        to: LIST_URL,
      },
      {
        label:
          "App Announcement Detail" +
          (subject
            ? ` - ${subject}`
            : ""),
      },
    ];
});

Would be great to have an example for this since I think the angular docs don't include anything regarding signals and forms together.

You can use signalSlice in this way, though your example does need some small changes — I have a video covering this use case (and some other form stuff) here: https://youtu.be/cxoew5rmwFM?si=rgbYhBJVIvf_SN4U&t=90 (time code is linked to section showing setting up form value changes using signalSlice)

🤯 yeah this is perfect. Great content to include in the docs as an example usage

I'm currently looking at tidying up some things around signalSlice now, including the docs, so yeah will probably add some sort of "Example Patterns" section to the bottom of the docs

I have been using Josh's approach in some projects recently and it works really nice. You can also subscribe to status changes like the value utility in the video and then combine/map it to the signal slice. One word of advice: you want to throw in a distinctUntilChange like this .distinctUntilChanged((a, b) => JSON.stringify(a) === JSON.stringify(b)) into the value and/or status change functions. Learned that today when I threw in some console logs lol.

It works even better with reactive forms with the new unified form events API coming in v18. pristine/touched can now be reacted to and not just with status and value changes anymore. Here is some functions I made with them like Josh's example, they are kind of all over the place at the moment. I haven't shared an example of hooking it up to the signal slice in that repo yet but you could do so easily with either the new API or the current value/status one.

I have proposed something like this in the discussions section of this project #334

edit: also, those { emitEvent: false } in the effects are very important, you don't want to exclude those when possible. You can very easily cause recursive issues without those.

Interesting examples @michael-small going to take a closer look at those later — since you've been using signalSlice you might be interested in weighing in on these upcoming changes: (removing actionEffects and effects APIs): #361 (adding replacement for actionEffects): #363

The other layer I need to support here is when the formGroup is coming via a (required) input. In this case, the example in the video fails because we don't yet have the input provided until later, so it throws.

I tried the following to get around the injection context issue by using a computed value to return the signalSlice but then I get an effect() error.

  formValues = computed(() => {
    const formGroup = this.formGroup();

    return runInInjectionContext(this.injector, () => {
      return signalSlice({
        initialState: getFormData(formGroup.controls),
        sources: [
          formGroup.valueChanges.pipe(
            map(() => getFormData(formGroup.controls)),
          ),
        ],
      });
    });
  });

I'm trying to use it like:

numTypedChars = computed(() => {
    const formValues = this.formValues()();
    const pendingMessage = formValues.pendingMessage;
    return pendingMessage?.length;
  });

Getting the value changes in sources shouldn't be a problem, you should be able to convert your input signal to an observable and derive a stream of value changes to give to sources that way

The tricky part is the initialState because it needs to be set immediately, I'm not sure if there is a clever way to get around this now (probably not) but theoretically I think if I modified the API to allow passing in a DestroyRef and Injector it might be feasible to allow creating a signalSlice outside of constructor time

There's potentially some foot guns here though - if the signalSlice is tied to the context of the service/component it is contained in, and the computed runs again, I think previous signalSlice objects won't be cleaned up properly and their subscriptions will keep running.

Needing the initialState at constructor time might just be a hard limitation here.

Getting the value changes in sources shouldn't be a problem, you should be able to convert your input signal to an observable and derive a stream of value changes to give to sources that way

In my experience doing it like that is tricky, as the input is not considered initialized by the time you try to access the .valueChanges. It throws some error I can't recall at the moment, probably the same one that Nate gets. And with a normal decorator input, there is no error, but it seemingly doesn't react, as if it is a snapshot of the form just being initialized. To work around it... and it is probably a busted solution... I basically have a boolean subject and in the ngOnInit I next it to true and then when I want to get the form values I just filter for the subject to be true.

The tricky part is the initialState because it needs to be set immediately

Haven't had an issue with this using my subject approach, though I need to reference the same initial values const wherever needed.

There's potentially some foot guns here though - if the signalSlice is tied to the context of the service/component it is contained in, and the computed runs again, I think previous signalSlice objects won't be cleaned up properly and their subscriptions will keep running.

Hmm good point I haven't thought about. I think before I invest more in the approach I aught to do some profiling (extra fancy console logs). For reference, we use the signal slice in our form service. Instantiate the form in there as well. Accessing a given control in a component is pretty easy, unless it happens to be an index in a FormArray. Like a whole form group in a component made for each group in an array. In that instance I pass the index as a signal input and then to get the form I use a computed with $form = computed(() => this.formService.form.controls.formArray.at($this.index()). That is the signalfied version, and for the sources end up doing this.$form().valueChanges.

I am going to piece together a stackblitz as I troubleshoot this stuff and when it is ready I can share it in this thread.

@michael-small the trick with using the input as a source will be to filter out any undefined values in the observable you create from the input signal, so the only thing it ever emits is a valid form control (this is just theory on my part though, I haven't actually tried this)

Can you link me to the subject approach you are talking about?

EDIT: Example of what I mean with the source, different situation but same basic idea: https://youtu.be/UUbakMjwQAc?si=6ybZo-D513Vb6L_M&t=241

@joshuamorony

  1. Trying what I think you are suggesting like the video
  2. The (Behavior*)Subject approach (I forgot it was not just a Subject)

  1. I used your approach like the video of just toObservable(this.$form) and then working from there and it works. That said, here is an example of not just that approach working, but also commented out is what I am (and probably Nate) getting when trying to use the form as a signal input

https://stackblitz.com/edit/ptxmym?file=src%2Fapp%2Fparent%2Fparent.component.ts

Relevant part

export class ChildComponent {
  // I think I can still use this as a signal for the most part when I
  //     do not need to access its value/status streams, but I will just
  //     use the observable in this example's template for consistency
  $form = input.required<
    FormGroup<{
      name: FormControl<string | undefined>;
    }>
  >();

  // This is what I would naturally try
  //     However, try uncommenting
  //
  // badFormValue$ = this.$form().valueChanges;

  // However, this works fine
  form$ = toObservable(this.$form);
  formValue$ = this.form$.pipe(switchMap((form) => form.valueChanges));

  error = `vendor.js:38885 ERROR Error: NG0950: Input is required but no value is available yet. Find more at https://angular.io/errors/NG0950
  at ChildComponent.inputValueFn [as $form] (vendor.js:32616:13)
  at new ChildComponent (main.js:26:28)
  at NodeInjectorFactory.ChildComponent_Factory [as factory] (main.js:31:10)
  at getNodeInjectable (vendor.js:38340:38)
  at instantiateAllDirectives (vendor.js:43595:23)
  at createDirectivesInstances (vendor.js:43017:3)
  at ɵɵelementStart (vendor.js:54416:5)
  at Module.ɵɵelement (vendor.js:54473:3)
  at ParentComponent_Template (main.js:113:62)
  at executeTemplate (vendor.js:42983:5)`;
}
  1. I don't think in comparison to the approach of (1) that this is that worth doing. Also I going to bed now so I rushed this. Check out Child with Behavior Subject*.
  formLoaded$ = new BehaviorSubject<boolean>(false);
  formValue$ = this.formLoaded$.pipe(
    filter(Boolean),
    switchMap(() => this.$form().valueChanges)
  );

  ngOnInit() {
    this.formLoaded$.next(true);
  }

edit: I have some followup notes when I follow up tomorrow


edit from "tomorrow": I was going to note a few things but after messing around with an example I burned myself out with some types confusion. These approaches for handling inputs still works fine for the slice approach, but there is a potential gotcha with sources typing that threw me through a loop. I will follow up on this later

@NateRadebaugh you may like this #391

@joshuamorony I have a question for you. Please see this comment in a PR I have made if you have the time to give me your thoughts. #391 (comment).

edit: Josh gave a good answer in that PR for reference, thanks

Note: I realized that despite the return signature, quirks of the forms API and values, it tends to when used return a partial. Despite .getRawValue, despite { nonNullable: true }, etc. I think I have been a bit pulled away from noticing this directly due to how some of my approaches of using it in effect kind of coerces partials.
I am going to try some tweaks on a few things to see if I can either avoid it becoming partials, and at worst just explicitly mark that it is effectively giving partials for value properties and some links to how to work with that.

When I say I didn't realize right away, my approach using my own util locally in my project is using it inside of signalSlice. I see now what you meant about ingesting partials from your video, and I see that internal util type in signalSlice that coerces partials to non partials and the intended type of the slice. I love that the slice does that, but I am hoping my proposed feature can stand alone from signalSlice, even though I think they were great as is.

Do you think I should just have the util more explicitly make the value a partial, or do you think there is something I can do to make it not be a partial? Like if there is some type wizardry like I see in signalSlice source? Do you think it would be sufficient to say it returns a partial, but then link to signalSlice and basically say that it works great as a sources source that is basically like this? I know from the other slice issues that there is some type weirdness so your thoughts would mean a lot.

state = signalSlice({
  initialState: this.initialState,
  sources: [allEventsObservable(this.form)],
});