Angular 2 form fundamentals: reactive forms
High-level terminology
Before we begin, let’s clarify what “reactive” forms mean from a high level.Reactive
When we talk about “reactive” forms (also known as model-driven), we’ll be avoiding directives such asngModel
, required
and friends. The idea is that instead of declaring that we want Angular
to power things for us, we can actually use the underlying APIs to do
them for us. In a sense, instead of binding Object models to directives
like template-driven forms, we in fact boot up our own instances inside a
component class and construct our own JavaScript models. This has much
more power and is extremely productive to work with as it allows us to
write expressive code, that is very testable and keeps all logic in the
same place, instead of scattering it around different form templates.Template-driven forms
If you’re yet to dive into “template-driven” forms, check out my previous post on it.Form base and interface
The base form structure that we’ll be using to implement our reactive form:<form novalidate>
<label>
<span>Full name</span>
<input
type="text"
name="name"
placeholder="Your full name">
</label>
<div>
<label>
<span>Email address</span>
<input
type="email"
name="email"
placeholder="Your email address">
</label>
<label>
<span>Confirm address</span>
<input
type="email"
name="confirm"
placeholder="Confirm your email address">
</label>
</div>
<button type="submit">Sign up</button>
</form>
Things we’ll implement:
- Bind to the user’s
name
,email
, andconfirm
inputs - Required validation on all inputs
- Show required validation errors
- Disabling submit until valid
- Submit function
// signup.interface.ts
export interface User {
name: string;
account: {
email: string;
confirm: string;
}
}
ngModule and reactive forms
Before we even dive into reactive forms, we need to tell our@NgModule
to use the ReactiveFormsModule
from @angular/forms
:import { ReactiveFormsModule } from '@angular/forms';
@NgModule({
imports: [
...,
ReactiveFormsModule
],
declarations: [...],
bootstrap: [...]
})
export class AppModule {}
@NgModule
definitions.Tip: useReactiveFormsModule
for reactive forms, andFormsModule
for template-driven forms.
Reactive approach
Let’s begin with a baseSignupFormComponent
and add our above template:// signup-form.component.ts
import { Component } from '@angular/core';
@Component({
selector: 'signup-form',
template: `
<form novalidate>...</form>
`
})
export class SignupFormComponent {
constructor() {}
}
FormControl
, FormGroup
, and finally move onto the amazing FormBuilder
.FormControl and FormGroup
Before digging into these APIs, I would strongly recommend checking out my previous article on template-driven forms to gain a better understanding of what’s happening.Let’s define what FormControl and FormGroup are:
- FormControl is a class that powers an individual form control, tracks the value and validation status, whilst offering a wide set of public API methods.
ngOnInit() {
this.myControl = new FormControl('Todd Motto');
}
- FormGroup is a group of FormControl instances, also keeps track of the value and validation status for the said group, also offers public APIs.
ngOnInit() {
this.myGroup = new FormGroup({
name: new FormControl('Todd Motto'),
location: new FormControl('England, UK')
});
}
FormControl
and FormGroup
, now how do we use them? It’s actually much easier than you’d think. Let’s assume we’ll bind our FormGroup
to a fresh code example before we continue with our signup form, so hopefully things click and you can follow easier:<form novalidate [formGroup]="myGroup">
Name: <input type="text" formControlName="name">
Location: <input type="text" formControlName="location">
</form>
Note: you’ll noticeThat’s it! On the form, we must declarengModel
andname=""
attributes have been toasted, this is good thing as it makes our markup less declarative (which can become complex, quickly, with forms)
[formGroup]
as a binding, and formControlName
as a directive with the corresponding Object key name. This is what we have:FormGroup -> 'myGroup'
FormControl -> 'name'
FormControl -> 'location'
Implementing our FormGroup model
So now we’ve learned the basis ofFormGroup
and FormControl
, we can think about implementing our own now. But first, what does our interface say?// signup.interface.ts
export interface User {
name: string;
account: {
email: string;
confirm: string;
}
}
FormGroup -> 'user'
FormControl -> 'name'
FormGroup -> 'account'
FormControl -> 'email'
FormControl -> 'confirm'
FormGroup
collections! Let’s make that come alive, but with no initial data:import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
@Component({...})
export class SignupFormComponent implements OnInit {
user: FormGroup;
ngOnInit() {
this.user = new FormGroup({
name: new FormControl(''),
account: new FormGroup({
email: new FormControl(''),
confirm: new FormControl('')
})
});
}
}
Binding our FormGroup model
Now we’ve instantiated theFormGroup
model, it’s obviously time to bind it to the DOM. Using what we’ve learned before, let’s go ahead:<form novalidate [formGroup]="user">
<label>
<span>Full name</span>
<input
type="text"
placeholder="Your full name"
formControlName="name">
</label>
<div formGroupName="account">
<label>
<span>Email address</span>
<input
type="email"
placeholder="Your email address"
formControlName="email">
</label>
<label>
<span>Confirm address</span>
<input
type="email"
placeholder="Confirm your email address"
formControlName="confirm">
</label>
</div>
<button type="submit">Sign up</button>
</form>
FormGroup
and FormControl
matches with the DOM structure:// JavaScript APIs
FormGroup -> 'user'
FormControl -> 'name'
FormGroup -> 'account'
FormControl -> 'email'
FormControl -> 'confirm'
// DOM bindings
formGroup -> 'user'
formControlName -> 'name'
formGroupName -> 'account'
formControlName -> 'email'
formControlName -> 'confirm'
#f="ngForm"
, and print f.value
in the DOM to check our form out, we do the opposite with reactive forms, as the [formGroup]
is a directive that we bind to, passing the public user
Object in:// { name: '', account: { email: '', confirm: '' } }
{{ user.value | json }}
Reactive submit
This is actually the exact same as the template-driven approach, however we can optionally reference the form internally to the component, instead of passing it in as a value. First, thengSubmit
value-passing:<form novalidate (ngSubmit)="onSubmit(user)" [formGroup]="user">
...
</form>
user
into the onSubmit()
? This allows us to pull down various pieces of information from our respective method on our component class:export class SignupFormComponent {
user: FormGroup;
onSubmit({ value, valid }: { value: User, valid: boolean }) {
console.log(value, valid);
}
}
value
and valid
properties from the user
reference we pass into onSubmit
. The value
is the same reference as printing user.value
out in the DOM. That’s literally it, you’re free to pass values to your backend API.Now, for the more internal approach. Because
this.user
is technically our model, we can simply reference the model onSubmit
internally, and not pass user
through as a function argument:export class SignupFormComponent {
user: FormGroup;
onSubmit() {
console.log(this.user.value, this.user.valid);
}
}
Reactive error validation
So far, we’ve implemented zero validation! Oh my. Let’s fix this. To add validation, we actually need to import the lovelyValidators
from @angular/forms
and pass them in as a second argument to our FormControl
instances:ngOnInit() {
this.user = new FormGroup({
name: new FormControl('', [Validators.required, Validators.minLength(2)]),
account: new FormGroup({
email: new FormControl('', Validators.required),
confirm: new FormControl('', Validators.required)
})
});
}
Rule: need multipleThis is now a replacement for addingValidators
perFormControl
? Use an array to contain them.
<input required>
to the DOM, which means we never have to touch it. Internally, when using required
directives in template-driven forms, Angular will actually create this
stuff under-the-hood for us, so that’s the main difference between the
two implementations.However, we are going to create
[disabled]
binding just like in the template-driven approach to disable the submit when the form is invalid:<form novalidate (ngSubmit)="onSubmit(user)" [formGroup]="user">
...
<button type="submit" [disabled]="user.invalid">Sign up</button>
</form>
.controls
property on the Object. Let’s say we want to show if there are any errors on the name
property of our form:<form novalidate [formGroup]="user">
{{ user.controls.name?.errors | json }}
</form>
Tip:We also have a?.prop
is called the “Safe navigation operator”
.get()
method that will lookup that control (I much prefer this as it’s a nicer API and avoids ?.errors
):<form novalidate [formGroup]="user">
{{ user.get('name').errors | json }}
</form>
<!-- name -->
<div
class="error"
*ngIf="user.get('name').hasError('required') && user.get('name').touched">
Name is required
</div>
<div
class="error"
*ngIf="user.get('name').hasError('minlength') && user.get('name').touched">
Minimum of 2 characters
</div>
<!-- account -->
<div
class="error"
*ngIf="user.get('account').get('email').hasError('required') && user.get('account').get('email').touched">
Email is required
</div>
<div
class="error"
*ngIf="user.get('account').get('confirm').hasError('required') && user.get('account').get('confirm').touched">
Confirming email is required
</div>
Tip: Thetouched
property becomestrue
once the user has blurred the input, which may be a relevant time to show the error if they’ve not filled anything out
Code so far
This is what we’ve achieved up until now:import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { User } from './signup.interface';
@Component({
selector: 'signup-form',
template: `
<form novalidate (ngSubmit)="onSubmit(user)" [formGroup]="user">
<label>
<span>Full name</span>
<input type="text" placeholder="Your full name" formControlName="name">
</label>
<div class="error" *ngIf="user.get('name').hasError('required') && user.get('name').touched">
Name is required
</div>
<div class="error" *ngIf="user.get('name').hasError('minlength') && user.get('name').touched">
Minimum of 2 characters
</div>
<div formGroupName="account">
<label>
<span>Email address</span>
<input type="email" placeholder="Your email address" formControlName="email">
</label>
<div
class="error"
*ngIf="user.get('account').get('email').hasError('required') && user.get('account').get('email').touched">
Email is required
</div>
<label>
<span>Confirm address</span>
<input type="email" placeholder="Confirm your email address" formControlName="confirm">
</label>
<div
class="error"
*ngIf="user.get('account').get('confirm').hasError('required') && user.get('account').get('confirm').touched">
Confirming email is required
</div>
</div>
<button type="submit" [disabled]="user.invalid">Sign up</button>
</form>
`
})
export class SignupFormComponent implements OnInit {
user: FormGroup;
constructor() {}
ngOnInit() {
this.user = new FormGroup({
name: new FormControl('', [Validators.required, Validators.minLength(2)]),
account: new FormGroup({
email: new FormControl('', Validators.required),
confirm: new FormControl('', Validators.required)
})
});
}
onSubmit({ value, valid }: { value: User, valid: boolean }) {
console.log(value, valid);
}
}
Simplifying with FormBuilder
This is where things get even smoother! Instead of usingFormGroup
and FormControl
directly, we can use a magical API underneath that does it all for us. Meet FormBuilder
!First up, we’ll need to change our imports from this:
import { FormControl, FormGroup, Validators } from '@angular/forms';
export class SignupFormComponent implements OnInit {
user: FormGroup;
constructor() {}
...
}
constructor
injection to make this.fb
available as the FormBuilder
):import { FormBuilder, FormGroup, Validators } from '@angular/forms';
export class SignupFormComponent implements OnInit {
user: FormGroup;
constructor(private fb: FormBuilder) {}
...
}
user: FormGroup;
on our component class is of type FormGroup
. So, what is FormBuilder
? It’s essentially syntax sugar that creates FormGroup
, FormControl
and FormArray
instances for us (we’ll cover FormArray
in another article). It’s just simple sugar, but now you know what it’s for.Let’s refactor our code to use
FormBuilder
:// before
ngOnInit() {
this.user = new FormGroup({
name: new FormControl('', [Validators.required, Validators.minLength(2)]),
account: new FormGroup({
email: new FormControl('', Validators.required),
confirm: new FormControl('', Validators.required)
})
});
}
// after
ngOnInit() {
this.user = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
account: this.fb.group({
email: ['', Validators.required],
confirm: ['', Validators.required]
})
});
}
Instead of using
new FormGroup()
for example, we’re injecting FormBuilder
as fb
, and creating a new this.fb.group()
.
The structure of these are identical to creating the controls and
groups by themselves, it’s just syntax sugar. Which leaves us with a
component class that looks like this:@Component({...})
export class SignupFormComponent implements OnInit {
user: FormGroup;
constructor(private fb: FormBuilder) {}
ngOnInit() {
this.user = this.fb.group({
name: ['', [Validators.required, Validators.minLength(2)]],
account: this.fb.group({
email: ['', Validators.required],
confirm: ['', Validators.required]
})
});
}
onSubmit({ value, valid }: { value: User, valid: boolean }) {
console.log(value, valid);
}
}
No comments:
Post a Comment