Angular offers two different methods for creating forms, template-driven and reactive forms. This articles gives a practical example how Angular 4 can be used to build a login with reactive forms.
Angular offers two different ways to build forms. One is a templative-driven approach, the other one a reactive approach (also called model-driven forms). Both belong to the the same library (@angular/forms). However, they differ heavily in the programming style we are going to use.
With reactive forms, we manage the flow of data between a data model and a form model. All form elements are created and managed in the component class. A reactive form is basically a tree of form control objects. The root is a form group, below the root are the form controls, such as input fields and checkboxes.
FormGroup: 'form' -- FormControl: 'username' -- FormControl: 'password'
Reactive forms have several benefits over template-driven forms:
- Reactive forms are easier to unit test.
- Reactive Forms are actually much more powerful.
- Updates of values are synchronous.
- Validity checks are synchronous.
- Reactive forms work without knowledge about the DOM.
- Reactive form do not use data binding to move data into and out of the form controls.
In the following, we will build a simple login form with two input fields and a submit button. We will ensure that a user fills both fields before he can submit the form. The build process and the validation logic is done following the principles of reactive forms.
What we will implement:
- Bind to the user’s username and password inputs
- Listen to input changes
- Required and minimal length validation on all inputs
- Output required validation errors
- Disabling submit button until valid
- Submit function
Creating a Form Group
Let’s start building our login form.
To add form controls to our form, we assign them to a FormGroup. Here, I use a FormBuilder to build the FormGroup. It is syntactic sugar for FormControl, FormGroup, and FormArray. To use it, you have to inject FormBuilder in the constructor of your component class.
constructor(private fb: FormBuilder, ...) { }
Afterwards, we create a FormGroup using the FormBuilder fb via this.fb.group().
private createForm(): FormGroup { return this.fb.group({ username: this.createFormInput(this.minlengthUsername), password: this.createFormInput(this.minlengthPassword) }); }
In the previous code snippet, we created two input fields called username and password. To remove code duplication, I create a function called createFormInput() that takes the required minimal length for a valid input.
private createFormInput(minInputLength: number): FormControl { return new FormControl('', [ Validators.minLength(minInputLength), Validators.required ]); }
Validating the Input
The required minimal length is ensured by a validator. A validator is a function which takes a form control as input and outputs a map of errors. The Validators class offers a handful of predefined validation rules, such as
- required: requires controls to have a non-empty value.
- email: performs email validation.
- minLength / maxLength: requires controls to have a value of a minimum/maximum length.
- min / max: requires controls to have a value less/greater than a number.
As we have seen in the previous code snippet, a form control can be applied with several validators. Of course, you can also create your own custom validator.
Dont’ forget to add the required HTML validation attribute when using the required validator function.
Listening to form value changes
Whenever the user changes one of the form controls within the form, we want to get notified in our component.
Each form control element implements the AbstractControl interface. Besides other methods, this class offers a function named valueChanges(). It returns an Observable which emits an event every time the value of the form control changes. We will subscribe to this event emitter and execute a function onValueChanged() every time an event is fired.
this.form.valueChanges.subscribe(data => this.onValueChanged(data));
Here, we subscribe to the form element itself. Thereby, we are notified about all its associated FormControl elements.
Output Validation Error Messages
Afterwards, the function onValueChanged() receives the data emitted by the Observable. We will use this information to check the validity of the data received and to output error messages in case of invalid data.
We could also have implemented the following functionality within the HTML template using *ngIf directives.
<div *ngIf="form.get('username').hasError('required') && form.get('username').touched"> Name is required </div>
However, in my oppinion it clutters the code and should be included in the login form component.
Therefore, we iterate over all our form controls and check their state. If a control is dirty (the control’s value has changed) and invalid, we output a predefined error message under the input field.
private onValueChanged(data?: any): void { if (!this.form) { return; } for (const field in this.formErrors) { // clear previous error message this.formErrors[field] = ''; const control = this.form.get(field); if (control && control.dirty && !control.valid) { const messages = this.validationMessages[field]; for (const key in control.errors) { this.formErrors[field] += messages[key] + ' '; } } } }
For each input field (here: username and password), we define which error message should be displayed in case of a validation error. Remember, we defined that both input fields are required and must have a certain minimal length.
formErrors = { 'username': '', 'password': '' }; validationMessages = { 'username': { 'required': 'Username is required.', 'minlength': 'Username must be at least ' + this.minlengthUsername + 'characters long.' }, 'password': { 'required': 'Password is required.', 'minlength': 'Password must be at least ' + this.minlengthPassword + 'characters long.' } };
Finally, the validation error message will be output under the respective input field.
<div *ngIf='formErrors.username'> {{formErrors.username}} </div>
The message will only be shown if the respective property in formErrors is set.
Submitting the Form
Finally, we want to submit our form. I chose to disable the submit button as long as the form is invalid. This is done by setting the disabled property of the submit button to [disabled]=’!form.valid’.
The valid attribute will only be valid in case all validators assigned to our form controls are valid.
<button kendoButton [primary]='true' type='submit' [disabled]='!form.valid'>Store login data</button>
Again, we will see how easy it is to get the value from our form controls with a reactive forms. The submit button is bound to the submit function with the ngSubmit property via event binding.
<form class='k-form form-login-block' [formGroup]='form' (ngSubmit)='submit(form.value)' novalidate>
Hereby, form.value represents an object with all associated form controls and their respective values. We can now easily access the values in our submit function by {username, password}. Here, we are using a new feature of ES6 called object destructuring. We extract the object’s properties into variables with the same name, therefore it is just short for {username: username, password: password}.
public submit({ username, password }): void { const loggedIn = this.loginService.login(username, password); if (!loggedIn) { console.error('Credentials could not be saved.'); } else { this.loginSuccessful(); } }
At this point, I omit further details concerning the login service. This service receives the user’s credentials and returns a boolean defining if the login was successful. If so, the user receives a message and is redirected to another page. Otherwise, an error message is shown.
Appendix: Kendo UI and Username
At last, I would like to mention to other libraries that might be beneficial to you.
First, the username package tries to read the username from environment variables. Its method username() returns a promise which I used in the function loadUsername().
private loadUsername(): void { username().then(name => this.form.patchValue({ username: name })); }
Here, I use patchValue() instead of setValue() to change the value of the form control named username. With setValue(), you assign every form control value at once, whereas patchValue() assigns values to specific controls in the form group.
Second, you might already have recognised certain CSS classes in the HTML template. The ones starting with k- belong to Kendo UI, a UI framework with a great number of Angular UI components.
Working example
Finally, here is a working example for the reactive login form we developed in this article. At last, I provide an excerpt of the dependencies you need to specify in your package.json for Angular 4 and Kendo UI.
login.component.html
<form class='k-form form-login-block' [formGroup]='form' (ngSubmit)='submit(form.value)'> <label class='k-form-field'> <b>Username <span class='k-required'>*</span></b> <input class='k-textbox' type='text' placeholder='Your username' formControlName='username' required autofocus/> <div *ngIf='formErrors.username'> {{formErrors.username}} </div> </label> <label class='k-form-field'> <b>Password <span class='k-required'>* </span></b> <input type='password' class='k-textbox' placeholder='Your password' formControlName='password'/> <div *ngIf='formErrors.password'> {{formErrors.password}} </div> </label> <button kendoButton [primary]='true' type='submit' [disabled]='!form.valid'>Store login data</button> </form>
login.component.ts
import { Component, OnInit } from '@angular/core'; import { FormGroup, Validators, FormBuilder, FormControl } from '@angular/forms'; import { Router } from '@angular/router'; import { LoginService } from './login.service'; const username = require('username'); @Component({ templateUrl: './login-dialog.component.html', styleUrls: ['./login-dialog.component.scss'] }) export class LoginDialogComponent implements OnInit { form: FormGroup; private readonly minlengthUsername = 5; private readonly minlengthPassword = 10; constructor(private router: Router, private fb: FormBuilder, private loginService: LoginService) { } ngOnInit(): void { this.form = this.createForm(); this.form.valueChanges.subscribe(data => this.onValueChanged(data)); this.loadUsername(); } private createForm(): FormGroup { return this.fb.group({ username: this.createFormInput(this.minlengthUsername), password: this.createFormInput(this.minlengthPassword) }); } private createFormInput(minInputLength: number): FormControl { return new FormControl('', [ Validators.minLength(minInputLength), Validators.required ]); } private loadUsername(): void { username().then(name => this.form.patchValue({ username: name })); } public submit({ username, password }): void { const loggedIn = this.loginService.login(username, password); if (!loggedIn) { console.error('Credentials could not be saved.'); } else { this.loginSuccessful(); } } private loginSuccessful(): void { this.redirect('/project-management'); console.log('Credentials saved.'); } private redirect(target: string): void { this.router.navigate([target]); } private onValueChanged(data?: any): void { if (!this.form) { return; } for (const field in this.formErrors) { // clear previous error message (if any) this.formErrors[field] = ''; const control = this.form.get(field); if (control && control.dirty && !control.valid) { const messages = this.validationMessages[field]; for (const key in control.errors) { this.formErrors[field] += messages[key] + ' '; } } } } formErrors = { 'username': '', 'password': '' }; validationMessages = { 'username': { 'required': 'Username is required.', 'minlength': 'Username must be at least ' + this.minlengthUsername + ' characters long.' }, 'password': { 'required': 'Password is required.', 'minlength': 'Password must be at least ' + this.minlengthPassword + ' characters long.' } }; }
Package.json dependencies
"dependencies": { "@angular/animations": "4.3.1", "@angular/common": "4.3.1", "@angular/compiler": "4.3.1", "@angular/core": "4.3.1", "@angular/forms": "4.3.1", "@angular/http": "4.3.1", "@angular/material": "2.0.0-beta.7", "@angular/platform-browser": "4.3.1", "@angular/platform-browser-dynamic": "4.3.1", "@angular/router": "4.3.1", "@progress/kendo-angular-buttons": "1.0.5", "@progress/kendo-angular-dialog": "1.0.4", "@progress/kendo-angular-dropdowns": "1.1.2", "@progress/kendo-angular-inputs": "1.0.7", "@progress/kendo-angular-intl": "1.2.1", "@progress/kendo-angular-l10n": "1.0.2", "@progress/kendo-angular-layout": "1.0.5", "@progress/kendo-data-query": "1.0.5", "@progress/kendo-theme-default": "2.38.2", "core-js": "2.4.1", "rxjs": "5.4.2", "username": "3.0.0", "zone.js": "0.8.14" }, "devDependencies": { "@angular/cli": "1.2.3", "@angular/compiler-cli": "4.2.6", "@ngtools/webpack": "1.5.2", // remainder omitted "ts-node": "3.2.1", "tslint": "5.5.0", "typescript": "2.4.2", "typings": "2.1.1", "webpack": "3.3.0", }