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 levelParentData
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 levelChildData
fields, and simply attaches it's own form to the incomingFormArray
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!