angulartypescriptangular-reactive-forms

How to reactively deal with a dynamic form in Angular Reactive Forms?


The problem

I'm not an advanced Angular user and feel like I'm doing something wrong, even though I can hack together a "working" solution. This is largely an Angular-specific software design question. The code examples are heavily abridged (read: made up) for this question, as I don't want to dump half my app on you.

Requirement

I am developing an app that has to show a form to the user. The actual form fields to show are only determined at runtime, and are being fetched from a server - think a survey, where we have to fetch the questions over HTTP. The actual data structure is a little more complicated than just straight-up questions - we also have, think, optional follow-up questions to some survey questions, validation, and so on. We also need to give the user some feedback on the answers they have submitted so far, not just validation, but extra information that may be relevant to them. The user may be required to complete multiple surveys, and should be able to pick which one they want to see.

My implementation so far

So far, so good. I want to use Reactive Forms (because the app is already using it elsewhere, and because the validation will come in handy for a bunch of different features). I can build a prototype by fetching the survey definition from the server, and feeding that into my hand-rolled buildForm(survey: SurveyDefinition): FormGroup<...> { ... } method that uses FormBuilder to build the actual FormGroup from the HTTP response. Then all I have to do on the survey.component.ts is

public formGroup: FormGroup<{...}> | undefined;

public async ngOnInit(): Promise<void> {
    const surveyDefinition = await this.surveyClient.fetchSurveyDefinition(this.defaultSurveyUrl);
    this.formGroup = this.buildForm(surveyDefinition);
}

On the template, I just bind <form [formGroup]="formGroup">...</form> and that's it. This works.

Also, for the additional information we want to show to the user, this, again, is very easy:

private valueChangeSubscription: Subscription | undefined;
public currentInfo$: BehaviorSubject<string> = new BehaviorSubject('');

public async ngOnInit(): Promise<void> {
    ...snip...
    
    this.valueChangeSubscription = this.formGroup.valueChanges.pipe(
        map(x => this.buildSomeInfo(x))
    ).subscribe(x => this.currentInfo$.next(x));
}

public ngOnDestroy(): void {
    this.valueChangeSubscription?.unsubscribe();
}

And that works too.

Where it breaks

Now I want the user to be able to switch between different surveys by selecting one from a dropdown. I add a onUserSelectedSurvey(surveyUrl: string) method and connect it to the dropdown. In this method, I use the same idea as used before in ngOnInit():

public async onUserSelectedSurvey(surveyUrl: string): Promise<void> {
    const surveyDefinition = await this.surveyClient.fetchSurveyDefinition(surveyUrl);
    this.formGroup = this.buildForm(surveyDefinition);
}

This looks straightforward, but doesn't actually work. My problem arises because I have subscribed to this.formGroup.valueChanges, so I can't just replace the old FormGroup with a new one, or my currentInfo$ will go stale.

What I've considered

I see a bunch of options to solve this, none of which, however, seem satisfactory:

Mix reactive properties and Reactive Forms

My first impulse is to change the type definition of this.formGroup from FormGroup<{...}> to BehaviorSubject<FormGroup<{...}>>, and bind it on the template using <form [formGroup]="formGroup | async">...</form>. At least this kind of works with the requirement to keep my subscription current, as I can just do:

    this.valueChangeSubscription = this.formGroup.pipe(
        switchMap(x => x.valueChanges),
        map(x => this.buildSomeInfo(x))
    ).subscribe(x => this.currentInfo$.next(x));

However, this is actually terrible, as any user input on the controls that are bound to this FormGroup now mutates the object inside the BehaviorSubject instead of creating a changed copy and calling next(). This just invites breakage.

Imperative updates

Do the updates to the FormGroup imperatively. When I receive a new SurveyDefinition, instead of replacing this.formGroup with a new one, manually go over everything on the current FormGroup to call .clear() on it, then imperatively muck about using FormArray.push(), FormControl.setValue() and so on until I've munged everything into the right shape. This would probably do it, but it doesn't feel very "reactive".

Forego Reactive Forms

Just don't use FormGroup, instead, bind every control on the template directly to a BehaviorSubject<string> using event binding. This might be my preferred solution right now. Of course, that means I'm not using Reactive Forms at all, and given that my requirements don't seem very unusual (update a form dynamically, subscribe to value changes), this would imply that Reactive Forms is actually quite useless for most (reactive) apps. It also means I have to reimplement all of what FormGroup and FormArray give me for free (such as change events propagating to their parent, validation, and so on).

Question

How do I build this with Angular, Reactive Forms and/or RxJS in a clean way?


Solution

  • Generally, when you have an unique variable formGroup that change, you unsubscribe and subscribe to value change

    //if exist the subscription unsubscribe
    
    this.valueChangeSubscription && this.valueChangeSubscription.unsubscribe()
    
    //create the formGroup
    this.formGroup=...
    
    //subscribe to formGroup.valueChange
    this.valueChangesubscription=this.formGroup.valueChanges.pipe(..)
    

    And in onDestroy don't forget unsubscribe

    ngOnDestroy()
    {
        this.valueChangeSubscription && this.valueChangeSubscription.unsubscribe()
    
    }
    

    BTW, Why is the reason to use promises, async, await... Observables are more friendly and closer to Angular.