Nested Reactive Forms in Angular2, Continued

If you haven't read Part 1 of this post, I suggest you jump over and check that out first, or else you may find yourself lost in the advanced operations we'll be discussing here. In this post, we're going to look at more advanced usages of this setup, including form submission, adding/removing children, autosaving, undo/redo, and resetting form state.

Recap

To recap, the final final architecture from Part 1 resulted in:

  • A ParentFormComponent who knows only about root level ParentData fields and how to prompt for them in inputs, nothing about it's children's structures or forms
  • A ChildListComponent who knows only about an array of children, and is responsible for managing the array, but not the contents or the associated forms
  • A ChildFormComponent who knows only about it's own root level ChildData fields, and simply attaches it's own form to the incoming FormArray

Advanced Usage

Now that we have the basic setup, let's look at some of the more advanced features and how easy it can be to wire them up into this structure.

Adding a new child

Adding a new child deals with modifying the array of children, so the responsibility falls on the ChildListComponent. Let's look at the previously withheld addChild method in the component:

// child-list.component.ts
addChild() {
    const child: ChildData = {
        id: Math.floor(Math.random() * 100),
        childField1: '',
        childField2: '',
        childHiddenField1: ''
    };

    this.children.push(child);
    this.cd.detectChanges();
    return false;
}
<!-- child-list.component.html -->
<a href="" (click)="addChild()">
    Add Child
</a>

To add a child, we simply need to add it to the data model array of children. Note that we don't need to adjust the FormArray, because as soon as we add it to the data model array, our *ngFor="let child of children" will update, causing the generation of a new app-child-form, which will internally create the form and add it to the FormArray.

Note that we do have to call detectChanges(). Without this, Angular complains because we've just modified the children array, which in turn adds a new FormControl to the children: FormArray. Because the initial values here are blank, and the fields are required in the childForm: FormGroup, this causes the parentForm to become invalidated. Without triggering change detection, Angular (in dev mode) notices and complains that the parentForm.valid field changes from true to false and it was unaware. Again - global form validity awareness.

Removing a child

// child-list.component.ts
removeChild(idx: number) {
    if (this.children.length > 1) {
        this.children.splice(idx, 1);
        (<FormArray>this.parentForm.get('children')).removeAt(idx);
    }
    return false;
}

This is the opposite of the add child behavior, in that we need to remove the child from the children: ChildData[] array. However, there's one more step here to remove it from the children: FormArray as well. As soon as we remove it from the data model array, the ngFor handles removing it's istance of <app-child-form>, but since we've previously added the child form to the FormArray, it's left hanging there attached to the parentForm if we don't remove it.

Submitting the Parent Form

Now that the entire form is wired up, the last thing we need to do it handle submission of the form. Let's look first at the markup:

<!-- parent-form.component.html -->
<form [formGroup]="parentForm"
      (ngSubmit)="onSubmit()">

    <!-- inputs and child-list -->

    <button type="submit" [disabled]="!parentForm.valid">
        Submit
    </button>
</form>

We'll look at the onSubmit function in one second, but the disabled attribute usage here is really handy. Because all of the nested components attached directly to the parent form, the parentForm.valid field will be updated in real-time based on the entire form, not just the inputs generated by the ParentFormComponent. So when we null out an input way down in a child, the button immediately disables. When we add a new child with empty initial values, the button immediately disables. Only to re-enable as soon as all validations are satisfied.

Finally, submitting the form is quite simple:

// parent-form.component.ts
onSubmit() {
    if (!this.parentForm.valid) {
        console.error('Parent Form invalid, preventing submission');
        return false;
    }

    const updatedParentData = _.mergeWith(this.parentData,
                                          this.parentForm.value,
                                          this.mergeCustomizer);

    // ... send updatedParentData off to your REST API and go get a beer

    return false;
}

This is where the built in ReactiveForms functionality comes in super handy. For any FormGroup, you can access it's current state of the inputs via parentForm.value, which will be an object matching the structure set up using your FormGroup/FormArray/FormControl objects. If you noticed throughout, we've matched all of our FormControl names to exactly the fields in our ParentData/ChildData objects - which means the resulting for value will be the same structure, and we can simply merge the data directly into our data model and send it off.

The use of a specialized mergeCustomizer is needed because, IIRC, the default behavior of LoDash's _.merge function is to blow away the old array with the new array. However, in our case where we have an array of child objects, we have to consider that we may not expose all fields into our child FormGroup. For example, we're not going to let users edit the id or potentially firstAdded/lastModified or other metadata about the child object. Therefore, our child FormGroup may only contain a subset of the fields of the original ChildData we generated a form for. So we need to find the original child object by id, where it exists, and merge into that to preserve fields not included in the forms.

private mergeCustomizer = (objValue, srcValue) => {
    if (_.isArray(objValue)) {
        if (_.isPlainObject(objValue[0]) || _.isPlainObject(srcValue[0])) {
            // If we found an array of objects, take our form values, and 
            // attempt to merge them into existing values in the data model, 
            // defaulting back to new empty object if none found.
            return srcValue.map(src => {
                const obj = _.find(objValue, { id: src.id });
                return _.mergeWith(obj || {}, src, this.mergeCustomizer);
            });
        }
        return srcValue;
    }
}

Super Advanced - autosave/undo/redo/reset

Last but not least, the functionality of ReactiveForms makes it pretty trivial to begin to think about handling more advanced form interactions:

  • Autosaving drafts of the form periodically, without the user having to click submit
  • Undo/redo to step forward and backward one edit at a time
  • Resetting the entire form to it's initial state

None of these are wired up in the example, but let's look at how we might try to wire them up in the ParentFormComponent

Autosaving

To autosave, we need to track changes as they happen. Conveniently, ReactiveForms do just that using an Observable to which you can subscribe to be notified with the new form value on every single change. Here, we can grab a full version of the parentForm after each change, and consider sending it off to our API in a draft state:

// parent-form.component.ts
ngAfterViewInit() {
    this.parentForm.valueChanges
        .subscribe(value => {
            const autosaveData = _.mergeWith(this.parentData,
                                             value,
                                             this.mergeCustomizer);
            // ... send to the API as a new draft revision
        });
}

Undo/Redo

To begin implementing an undo operation, we'd need to save off version of the form at each step, that we could re-initialize back to if the user wanted to undo:

// parent-form.component.ts
private undoStates: ParentData[] = [];

ngAfterViewInit() {
    this.parentForm.valueChanges
        .subscribe(value => {
            const currentState = _.mergeWith(this.parentData,
                                             value,
                                             this.mergeCustomizer)
            undoStates.push(currentState);
            // ... Now, to perform an "undo" we could theoretically just 
            // re-populate the entire form with any entry from undoStates
        });
}

undo() {
    this.parentData = undoStates.pop();
    // At this point, there would need to be some cleanup performed on 
    // existing formControls.  Similar to how we removed the FormArray entry 
    // when we removed a config, if this undoState was going back to a 
    // smaller number of children - we'd need to find a way to get the 
    // stale child FormControls removed from the child FormArray
}

Redo would be a little more complex, and would involve not popping an undoState off, but maintaining an index into the undo State that could be moved forward and backwards.

Resetting the entire form to it's initial state

This could be pretty easily tied in with the undoStates above, and reverting the user back to undoStates[0]. But without worrying about undo/redo, it can be even simpler if we simply cache off a version of the form when we first render:

// parent-form.component.ts
private initialData: ParentData;

ngOnInit() {
    // Cache off the initial state
    this.initialState = this.getParentData();
    // Generate our initial form from a clone
    this.parentData = _.cloneDeep(this.initialState);
    this.parentForm = this.toFormGroup(this.parentData);
}

reset() {
    this.parentData = _.cloneDeep(this.initialState);
    // Same logic applies here for cleaning up `FormControls` as needed
}

Summary

In the end, I was pleasantly surprised with how easy Angular2's new ReactiveForms module made it to manage form logic in controllers instead of templates, and how easy it made it to separate business logic from templates and across components. I'm sure there's further improvements that could be made on this architecture, but for a first pass over about 2 days, I was really excited how easy it was to build a multiple-level nested form over a fairly complex data structure. Comments aren't yet wired up on this blog (only so many hours in a day), but feel free to reach out to me on Twitter with any comments or suggestions. Thanks for reading!