Taking user input using a form is a standard feature in a single page application. In this article, I’m comparing how client-side form validation can be implemented in Angular (version 6.0.3), React (version 16.4.1), and Vue (version 2.5.16).
I’ve created a simple form with the following fields and the associated validations.
Field Name | Validations |
---|---|
Name | Required |
Phone | Must be in a specific format: XXX-XXX-XXXX |
Username | Must not be registered before (the actual validation is done on a remote server) |
Password | No validations |
Confirm Password | Must match Password field |
Sample Code
Angular
Angular comes with FormsModule
which provides the framework to validate your form. When you add the directive NgForm
to your form, the state of the controls within the form is tracked which allows invoking the validation process at the right moment. When a control enforce multiple validation rules, the specific violated rules are also tracked.
State | Description |
---|---|
Touched | Whether the control has been visited. |
Changed | Whether the control’s value has changed |
Valid | Whether the control’s value is valid. |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
<h1>Application Form</h1> <form #appForm="ngForm" appPasswordConfirmed> <div class="form-group"> <label for="name">Name</label> <input type="text" name="name" [(ngModel)]="model.name" #name="ngModel" required /> <div *ngIf="name.invalid && (name.dirty || name.touched)"> <div *ngIf="name.errors.required"> Name is required. </div> </div> </div> <div class="form-group"> <label for="phone">Phone</label> <input type="text" name="phone" [(ngModel)]="model.phone" #phone="ngModel" appPhoneNumber /> <div *ngIf="phone.invalid && (phone.dirty || phone.touched)"> <div *ngIf="phone.errors.phoneNumber"> Phone number must be in valid format (e.g., 123-456-7890). </div> </div> </div> <div class="form-group"> <label for="username">Username</label> <input type="text" name="username" [(ngModel)]="model.username" #username="ngModel" appUniqueUsername /> <div *ngIf="username.invalid && (username.dirty || username.touched)"> <div *ngIf="username.errors.uniqueUsername"> Username is taken. </div> </div> </div> <div class="form-group"> <label for="password">Password</label> <input type="text" name="password" [(ngModel)]="model.password" /> </div> <div class="form-group"> <label for="confirm-password">Confirm Password</label> <input type="password" name="confirm-password" [(ngModel)]="model.confirmPassword" /> <div *ngIf="appForm.errors?.passwordConfirmed && (appForm.touched || appForm.dirty)"> Password must match. </div> </div> <button type="submit">Submit</button> </form> |
Angular not only provides several built-in validators, but it also provides the framework to create a custom validator. This custom validator is implemented as a directive and the new validation rule can be added to a control by adding its selector to that control. When validation involves two controls (such as comparing the values in two controls), the custom validator directive (in this case, PasswordConfirmedValidatorDirective
) must be applied on the form element. I’ve got more intuitive way in mind (as implemented in React and Vue app) to do it, but that’s what the documentation suggests.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
import { Directive } from '@angular/core'; import { AbstractControl, Validator, NG_VALIDATORS } from '@angular/forms'; @Directive({ selector: '[appPhoneNumber]', providers: [{ provide: NG_VALIDATORS, useExisting: PhoneNumberValidatorDirective, multi: true}] }) export class PhoneNumberValidatorDirective implements Validator { validate(control: AbstractControl): {[key: string]: any} | null { const isValidPhoneNumber = /^\d{3}-\d{3}-\d{4}$/.test(control.value); const message = { 'phoneNumber': { value: control.value } }; return isValidPhoneNumber ? null : message; } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
import { Directive } from '@angular/core'; import { AbstractControl, Validator, NG_VALIDATORS, FormGroup, ValidatorFn, ValidationErrors } from '@angular/forms'; export const passwordConfirmedValidator: ValidatorFn = (control: FormGroup): ValidationErrors | null => { const password = control.get('password'); const confirmPassword = control.get('confirm-password'); return password && confirmPassword && (confirmPassword.touched || confirmPassword.dirty) && password.value !== confirmPassword.value ? { 'passwordConfirmed': true } : null; }; @Directive({ selector: '[appPasswordConfirmed]', providers: [{provide: NG_VALIDATORS, useExisting: PasswordConfirmedValidatorDirective, multi: true}] }) export class PasswordConfirmedValidatorDirective implements Validator { validate(control: AbstractControl): {[key: string]: any} | null { return passwordConfirmedValidator(control); }; } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import { Directive } from '@angular/core'; import { AbstractControl, Validator, NG_ASYNC_VALIDATORS, ValidationErrors } from '@angular/forms'; import { Observable, of } from "rxjs"; @Directive({ selector: '[appUniqueUsername]', providers: [{provide: NG_ASYNC_VALIDATORS, useExisting: UniqueUsernameValidatorDirective, multi: true}] }) export class UniqueUsernameValidatorDirective implements Validator { validate(control: AbstractControl): Observable<ValidationErrors | null> { // Let's pretend this validator hits a remote server and checks if // username is taken const isUniqueUsername = control.value !== 'bob'; const message = { 'uniqueUsername': true }; return of(isUniqueUsername ? null : message); } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { AppComponent } from './app.component'; import { ApplicationFormComponent } from './application-form/application-form.component'; import { PhoneNumberValidatorDirective } from './validators/phone-number.directive'; import { PasswordConfirmedValidatorDirective } from './validators/password-confirmed.directive'; import { UniqueUsernameValidatorDirective } from './validators/unique-username.directive' @NgModule({ declarations: [ AppComponent, ApplicationFormComponent, PhoneNumberValidatorDirective, PasswordConfirmedValidatorDirective, UniqueUsernameValidatorDirective ], imports: [ BrowserModule, FormsModule ], providers: [], bootstrap: [AppComponent] }) export class AppModule { } |
React
React doesn’t come with any form validation feature. Its documentation doesn’t provide recommendation for third-party validation libraries either.
When I was looking for a React validation library, I found several libraries with different paradigms. Based on your programming experience, one paradigm will feel more intuitive than the other. I decided to use React Advanced Form to enable form validation on this app. I also installed the companion library react-advanced-form-addons
which provides form controls with support to display validation errors.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
import React, { Component } from 'react'; import { Form } from 'react-advanced-form'; import { Input } from 'react-advanced-form-addons'; import { ConfirmPasswordInput } from './ConfirmPasswordInput'; import validationMessages from './validationMessages'; import validationRules from './validationRules'; export class ApplicationForm extends Component { validateUsername = ({ value, fieldProps, fields, form }) => { // Let's pretend this validator hits a remote server and checks if // username is taken return Promise.resolve({ valid: value !== 'bob' }); } render() { return ( <div className="App"> <Form action={this.registerUser} rules={validationRules} messages={validationMessages}> <Input name="name" label="Name" required /> <Input name="phone" label="Phone" /> <Input name="username" label="Username" asyncRule={this.validateUsername} /> <Input name="password" label="Password" type="password" /> <ConfirmPasswordInput label="Confirm Password" passwordInput="password" /> </Form> </div> ); } } |
1 2 3 4 5 6 7 8 |
export default { name: { 'phone': ({value}) => { const isValidPhoneNumber = /^\d{3}-\d{3}-\d{4}$/.test(value); return isValidPhoneNumber; } } } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
export default { general: { missing: 'Please provide the required field.', invalid: 'Provided value is invalid.' }, name: { 'username': { async: 'Username is taken.' }, 'phone': { invalid: 'Phone number must be in valid format (e.g., 123-456-7890).' }, 'confirmPassword': { invalid: 'Password must match.' } } }; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import React, { Component } from 'react'; import { Input } from 'react-advanced-form-addons'; export class ConfirmPasswordInput extends Component { confirmPassword = ({value, fieldProps, fields, form}) => { if (fields[this.props.passwordInput]) { return value === fields[this.props.passwordInput].value; } else { return false; } } render() { return ( <Input name="confirmPassword" label={this.props.label} type="password" rule={this.confirmPassword} /> ); } } |
Vue
Vue doesn’t provide a built-in form validation library, but its documentation provides an example how to do it by hand. It also lists a couple third-party validation libraries. I use Veevalidate for this Vue app. VeeValidate ships with a lot of built-in validation rules, including one that you can use to make sure a user enters new password correctly.
1 2 3 4 5 6 7 8 9 10 |
import Vue from 'vue' import VeeValidate from 'vee-validate'; import App from './App.vue' Vue.config.productionTip = false; Vue.use(VeeValidate); new Vue({ render: h => h(App) }).$mount('#app') |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 |
<template> <div class="container"> <h1>Application Form</h1> <form> <div class="form-group"> <label for="name">Name</label> <input type="text" name="name" v-validate required /> <div v-show="errors.has('name')">{{errors.first('name')}}</div> </div> <div class="form-group"> <label for="phone">Phone</label> <input type="text" name="phone" v-validate="'phone_number'" /> <div v-show="errors.has('phone')">{{errors.first('phone')}}</div> </div> <div class="form-group"> <label for="username">Username</label> <input type="text" name="username" v-validate="'unique_username'" /> <div v-show="errors.has('username')">{{errors.first('username')}}</div> </div> <div class="form-group"> <label for="password">Password</label> <input type="password" name="password" v-validate="'confirmed:confirmation'" /> </div> <div class="form-group"> <label for="confirmPassword">Confirm Password</label> <input type="password" name="confirmPassword" ref="confirmation" /> <div v-show="errors.has('password')">{{errors.first('password')}}</div> </div> <button type="submit">Submit</button> </form> </div> </template> <script> import { Validator } from 'vee-validate'; import VeeValidators from '../validators/vee-validators.js'; Validator.extend('phone_number', VeeValidators.phoneNumber); Validator.extend('unique_username', VeeValidators.uniqueUsername); export default { name: 'application-form' } </script> <style> body { padding: 2em; } h1 { font-size: 1.6em; } label { display: block; } input { margin-bottom: .2em; } input + div { color: red; } .form-group { margin-bottom: 1em; } </style> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import { Validator } from 'vee-validate'; export default { phoneNumber: { getMessage(field, args) { return 'Phone number must be in valid format (e.g., 123-456-7890).'; }, validate(value, args) { const isValidPhoneNumber = /^\d{3}-\d{3}-\d{4}$/.test(value); return isValidPhoneNumber; } }, uniqueUsername : { getMessage(field, args, data) { return (data && data.message) || 'Something went wrong'; }, validate(value, args, data) { // Let's pretend this validator hits a remote server and checks if // username is taken const isUniqueUsername = value !== 'bob'; const message = { valid: isUniqueUsername, data: { message: 'Username is taken' } } return Promise.resolve(message); } } } |
The form validation functionality provided by Angular is a good start toward a complete validation framework customized to your app. I also like the reusability of an Angular directive, but the boilerplate code adds so much noise.
To start from a scratch, React and Vue needs implementation of events (such as touched, changed, and valid) and only then we can implement the actual validation process. Finding a thirdy-party validation library may not be easy even when there are plenty of options. One solution seems more intuitive than then others and it feels like it’s better to pick the most basic one which can be used as a foundation.