If you build real business applications, then you know most of your development effort goes into forms. AngularJS struck me right away as an amazing improvement over the previous generation of HTML libraries, simply because model binding was really well done. Integrating binding with validation made it a first-class web framework.
Business applications of any moderate complexity often have reusable user controls and AngularJS directives were great for that purpose. AngularJS had this amazing feature, whereby placing an ng-form
inside of another ng-form
the state of the child form was automatically reflected in the parent! You could build up directives with their own validation completely independent of what page they belonged to. Simply by dropping them on your page, all of your validation was auto-wired up. Brilliant!
It is a little surprising, then, that Angular2 did not follow in this tradition. If you search the Internet for "Angular2 nested forms" you'll find a dizzying array of StackOverflow questions and blogs that basically tell you "you can't do it that way anymore". Whaaat? It's a little counter-intuitive at first because you'll find the ngModelGroup
class whose purpose it seems is to group together form elements and validation. But the docs explicit say they must appear directly under an ngForm
. Boo...
Instead, the Angular2 team decided, for these more advanced scenarios, we developers would be better off using the Reactive style form builder classes. While this might be solid advice for some scenarios, I would guess most developers want to keep working in something similar to AngularJS and prefer the more declarative style of form building. Working mostly in HTML gives me a closer picture of what my page is going to look like. It is much easier to switch between "designer mode" and "coder mode".
Credit goes to Johannes Rudolph for his brilliant StackOverflow post suggesting simply giving each child component it's own ngForm
and exposing that member (via ViewChild
) as a public property. Note you should only have one <form>
element per page, so just put the ngForm
on a <div>
instead.
Let's start with this simple example:
@Component({
selector: "account-name-panel",
template: `
<div #nameForm="ngForm" ngForm="nameForm">
<label for="textName">Account Name</label>
<input #textName="ngModel"
id="textName"
name="textName"
type="text"
maxlength="100"
[(ngModel)]="accountName"
required />
</div>`
})
export class AccountNamePanelComponent {
@ViewChild("nameForm") public form: NgForm = null;
public accountName: string = null;
}
Basically, ViewChild
, will look at the template and assign the element with the given name to the backing member form
. Since we set #nameForm=ngForm
, Angular2 will take care to make sure form
is an instance of NgForm
. This is a great start!
Now when we add <account-name-panel>
to another form, we can use ViewChild
again to grab an instance of the AccountNamePanelComponent
class. From there, we can access the form
member to ask it if it is valid or not.
NgForm
provides functions for modifying the state of the form: onSubmit($event)
and onReset()
. Additionally, there are flags to see the current state of the form: submitted
, dirty
, pristine
, valid
and invalid
. Look at the docs for NgForm
to see additional options.
Unfortunately, this approach has some drawbacks:
For one, it means exposing a bit about the internal state of your components. You might realize a really complex component needs to have its own child forms that would then also need registered somehow. You'd have to modify every parent form using that child.
If you place a child within an *ngIf
, the component will go in and out of existence with your logic; there's no convenient component life-time hook to detect when a child component is created/destroyed either.
Another drawback is that you have to manually check every child form at submission time. Personally, I was adding NgForm
objects to an array and then using lodash to check to see if there were any invalid forms:
const forms = [this.mainForm, this.childForm1, this.childForm2];
const isInvalid = _(forms).some((form: NgForm) => form.invalid);
I would often place a forms.filter((f) => f != null)
in there, too, just to be safe. After my second form, I created a simple helper class to do this logic for me, call it FormHelper
.
export class FormHelper {
private readonly forms: NgForm[] = [];
public add(form: NgForm): void {
if (form == null) {
return;
}
this.forms.push(form);
}
public get isValid(): boolean {
return !_(this.forms).some((f: NgForm) => f.invalid);
}
// ... etc.
}
I was working on my fourth or fifth form when I finally got tired of building up FormHelper
s. My forms were sporting 5+ child forms and I had to wrap each add
call with a null
check to make sure the child component wasn't null. Worse, I had to build upa new FormHelper
everytime I used it because my child components came in and out of existence as data was loading asynchronously.
Fortunately, an idea had already been brewing in my mind for a couple weeks. What set me over the edge was an article that a friend of mine had recently forwarded to me about Heirarchical Dependency Injection in Angular2. Basically, if you explicitly list out a type in the providers
section of a component, it gets its own copy of the dependency. Any child components will inherit the parent's copy.
I also recently searched around for ways to detect when a child component became accessible to the parent. Like I said, there is no convenient life-time hook to tap into. I found this answer that, in short, suggests using a property "setter" to detect the change:
@ViewChild(ChildComponent)
private set childSetter(child: ChildComponent) {
// Do something...
}
An alternative would be to create a separate EventEmitter
for the parent to listen to, but then the parent would need to wire in explicitly. I'm trying to avoid the parent needing to do anything.
So how do I combine all these seemingly random tricks?
First, I went to my child components and changed them to look like this:
@Component({
selector: "account-name-panel",
template: `
<div #nameForm="ngForm" ngForm="nameForm">
<label for="textName">Account Name</label>
<input #textName="ngModel"
id="textName"
name="textName"
type="text"
maxlength="100"
[(ngModel)]="accountName"
required />
</div>`
})
export class AccountNamePanelComponent {
public constructor(private formHelper: FormHelper) {
}
@ViewChild("nameForm")
private set formSetter(form: NgForm) {
this.formHelper.add(form);
}
public accountName: string = null;
}
Notice that I am using a setter property formSetter
to add the NgForm
in the component to the FormHelper
. I have to pass that FormHelper
to the constructor of the child component so it has access to it. In order to pass FormHelper
, I had to make it Injectable()
:
@Injectable()
export class FormHelper {
// ...the rest stays the same
}
Then, in the top-most parent component, I add the providers
section so that each page gets its own FormHelper
; you wouldn't want to share the same instance across you entire application! You'll need to pass this FormHelper
to the constructor to have access to it again:
@Component({
selector: "parent-form",
template: `<div>...</div>`,
providers: [FormHelper]
})
export class ParentComponent {
public constructor(private formHelper: FormHelper) {
}
}
Basically, FormHelper
becomes a shared service that the parent and children use to report their statuses. The @ViewChild
setters in the child components take care of wiring up their forms.
One final step is to make sure you don't re-register the same child form multiple times and that you remove forms when null
gets passed to the setter functions. To handle that, I switched to using a Map<string, NgForm>
rather than just a simple array and renamed add
to register
. Here's what my final FormHelper
looked like in my application:
@Injectable()
export class FormHelper {
private formLookup = new Map<string, NgForm>();
private _isSubmitted: boolean = false;
public register(name: string, form: NgForm): void {
if (form == null) {
this.formLookup.delete(name);
} else {
this.formLookup.set(name, form);
}
}
public get isSubmitted(): boolean {
return this._isSubmitted;
}
public get isValid(): boolean {
const forms: NgForm[] = Array.from(this.formLookup.values());
const isInvalid = _(forms).some((f: NgForm) => f.invalid);
return !isInvalid;
}
public submit(): void {
this.formLookup.forEach((form) => {
form.onSubmit(null);
});
this._isSubmitted = true;
}
public reset(): void {
this.formLookup.forEach((form) => {
form.onReset();
});
this._isSubmitted = false;
}
}
With these simple changes, you can add a child component to any page and it will automatically wire itself into the FormHelper
, giving you access to the overall state of your page. This works just as well for grandchild components, too. Woo!
In summary, it would be nice if model-driven forms just worked like they used to. However, the approach I show here is pretty straight-forward and easy to extend. It doesn't involve knowing much about Angular2, either. For me, a declarative approach just seems like the better alternative and I am glad I was able to stick to it without too much code. Hopefully this will find its way out to other developers.
This workaround could also be helpful.