Angular:

Reactive Forms

Doguhan Uluca

Angular:

Reactive Forms

Technical Fellow @

  1. Grab a hose
  2. Spray water into the heater
  3. Turn on the faucet for hot water
  4. Send a text to the utility company
  5. Don't forget to undo your steps, when done

Imperative

  1. Turn on/off the faucet for hot water

Reactive

Reactive

products$ = this.http.get<Product[]>(this.productsUrl)
    .pipe(
      tap(data => console.log('getProducts: ', JSON.stringify(data))),
      shareReplay(),
      catchError(this.handleError)
    )

Reactive

productsWithCategory$ = combineLatest(
    this.products$,
    this.productCategoryService.productCategories$
  ).pipe(
    map(([products, categories]) =>
      products.map(
        p =>
          ({
            ...p,
            category: categories.find(c => p.categoryId === c.id).name
          } as Product) // <-- note the type here!
      )
    ),
    shareReplay()
  )

Reactive

Data Composition with RxJS | Deborah Kurata

Thinking Reactively: Most Difficult | Mike Pearson

Doguhan Uluca

  1. FormControl
  2. Validations Reuse
  3. FormBuilder with Stepper
  4. Scaling with Form Parts Reuse
  5. Drop down with type-ahead support
  6. Form Arrays
  7. Ngx-Mask
  8. ControlValueAccessor
 

FormControl

FormControl

duluca/local-weather-app/city-search.component.ts

search = new FormControl('', [Validators.required, Validators.minLength(2)])

this.search.valueChanges
  .pipe(
    debounceTime(1000),
    filter(() => !this.search.invalid),
    tap((searchValue: string) => this.doSearch(searchValue))
  )
  .subscribe()

FormControl

FormControl

duluca/local-weather-app/city-search.component.html

<form #searchForm>
  <mat-form-field appearance="outline"
    [ngClass]="search.value ? 'search-box-partial' : 'search-box-full'">
    <mat-label>City Name or Postal Code</mat-label>
    <mat-icon matPrefix>search</mat-icon>
    <input matInput aria-label="City or Zip" [formControl]="search" />
    <mat-hint>Specify country code like 'Paris, US'</mat-hint>
    <mat-error *ngIf="search.invalid"> Type more than one character to search
    </mat-error>
  </mat-form-field>
  <button mat-icon-button *ngIf="search.value" class="clear-button"
    (click)="searchForm.reset(); search.reset();">
    <mat-icon>close</mat-icon>
  </button>
 </form>

FormControl

Validations Reuse

new FormControl('', 
  [Validators.required, Validators.minLength(2)]
)

Validations Reuse

duluca/lemon-mart/common/validations.ts

export const OptionalTextValidation = [Validators.minLength(2), Validators.maxLength(50)]
export const RequiredTextValidation = OptionalTextValidation.concat([Validators.required])
export const EmailValidation = [Validators.required, Validators.email]
export const PasswordValidation = ...
export const BirthDateValidation = [
  Validators.required,
  Validators.min(new Date().getFullYear() - 100),
  Validators.max(new Date().getFullYear()),
]
export const USAZipCodeValidation = [
  Validators.required,
  Validators.pattern(/^\d{5}(?:[-\s]\d{4})?$/),
]
export const USAPhoneNumberValidation = ...

FormBuilder

FormBuilder

FormBuilder

FormBuilder

duluca/lemon-mart/user/profile/profile.component.ts  

userForm: FormGroup

constructor(
  private formBuilder: FormBuilder
) {}

ngOnInit() {
  const user = ...
  this.buildUserForm(user)
}

buildUserForm(user?: IUser, currentUserRole = UserRole.None) {
  this.userForm = this.formBuilder.group({
    ...
  })
}

FormBuilder

duluca/lemon-mart/user/profile/profile.component.html

<mat-horizontal-stepper #stepper="matHorizontalStepper">
  <mat-step [stepControl]="userForm">
    <form [formGroup]="userForm">
      <ng-template matStepLabel>Account Information</ng-template>
      <div class="stepContent">
        ...
      </div>
      <div fxLayout="row" class="margin-top">
        <div class="flex-spacer"></div>
        <div *ngIf="userError" class="mat-caption error">{{ userError }}</div>
        <button mat-raised-button matStepperNext color="accent">Next</button>
      </div>
    </form>
  </mat-step>
  <mat-step [stepControl]="userForm">
    ...
  </mat-step>
  <mat-step [stepControl]="userForm">
   ...
  </mat-step>
</mat-horizontal-stepper>

FormBuilder

FormBuilder

duluca/lemon-mart/user/profile/profile.component.ts  

buildUserForm(user?: IUser, currentUserRole = UserRole.None) {
  this.userForm = this.formBuilder.group({
      email: [{
          value: (user && user.email) || '',
          disabled: currentUserRole !== this.Role.Manager,
        },
        EmailValidation,
      ],
      name: this.formBuilder.group({
        first: [(user && user.name.first) || '', RequiredTextValidation],
        middle: [(user && user.name.middle) || '', OneCharValidation],
        last: [(user && user.name.last) || '', RequiredTextValidation],
      }),
      role: [{
          value: (user && user.role) || '',
          disabled: currentUserRole !== this.Role.Manager,
        },
        [Validators.required],
      ],
   ...

FormBuilder

duluca/lemon-mart/user/profile/profile.component.html  

<div class="stepContent">
  <div fxLayout="row" fxLayout.lt-sm="column"
    [formGroup]="userForm.get('name')" fxLayoutGap="10px">
    <mat-form-field appearance="outline" fxFlex="40%">
      <mat-label>First Name</mat-label>
      <input matInput aria-label="First Name" formControlName="first" />
      <mat-error *ngIf="userForm.get('name').get('first').hasError('required')">
        First Name is required
      </mat-error>
      <mat-error *ngIf="userForm.get('name').get('first').hasError('minLength')">
        Must be at least 2 characters
      </mat-error>
      <mat-error *ngIf="userForm.get('name').get('first').hasError('maxLength')">
        Can't exceed 50 characters
      </mat-error>
    </mat-form-field>
    <mat-form-field appearance="outline" fxFlex="20%">
      <mat-label>MI</mat-label>
      <input matInput aria-label="Middle Initial" formControlName="middle" />
      <mat-error *ngIf="userForm.get('name').get('middle').invalid">
        Only inital
      </mat-error>
    </mat-form-field>
    <mat-form-field appearance="outline" fxFlex="40%">
      <mat-label>Last Name</mat-label>
      <input matInput aria-label="Last Name" formControlName="last" />
      <mat-error *ngIf="userForm.get('name').get('last').hasError('required')">
        Last Name is required
      </mat-error>
      <mat-error *ngIf="userForm.get('name').get('last').hasError('minLength')">
        Must be at least 2 characters
      </mat-error>
      <mat-error *ngIf="userForm.get('name').get('last').hasError('maxLength')">
        Can't exceed 50 characters
      </mat-error>
    </mat-form-field>
  </div>
  <div fxLayout="row" fxLayout.lt-sm="column" fxLayoutGap="10px">
    <mat-form-field appearance="outline" fxFlex="50%">
      <mat-label>Date of Birth</mat-label>
      <input matInput aria-label="Date of Birth" formControlName="dateOfBirth"
        [matDatepicker]="dateOfBirthPicker" />
      <mat-hint *ngIf="userForm.get('dateOfBirth').touched">
        {{ this.age }} year(s) old
      </mat-hint>
      <mat-datepicker-toggle matSuffix [for]="dateOfBirthPicker">
      </mat-datepicker-toggle>
      <mat-datepicker #dateOfBirthPicker></mat-datepicker>
      <mat-error *ngIf="userForm.get('dateOfBirth').invalid">
        Date must be with the last 100 years
      </mat-error>
    </mat-form-field>
    <mat-form-field appearance="outline" fxFlex="50%">
      <mat-label>E-mail</mat-label>
      <input matInput aria-label="E-mail" formControlName="email" />
      <mat-hint>Only your manager can update your e-mail.</mat-hint>
      <mat-error *ngIf="userForm.get('email').invalid">
        A valid E-mail is required
      </mat-error>
    </mat-form-field>
  </div>
  <div fxLayout="row" fxLayout.lt-sm="column" class="margin-top" fxLayoutGap="10px">
    <div fxFlex>
      <mat-label class="mat-body-1">Role</mat-label>
      <mat-radio-group formControlName="role">
        <mat-radio-button style="margin-right: 10px;" [value]="Role.None">
          <span class="mat-body-1">None</span>
        </mat-radio-button>
        <mat-radio-button style="margin-right: 10px;" [value]="Role.Cashier">
          <span class="mat-body-1">Cashier</span>
        </mat-radio-button>
        <mat-radio-button style="margin-right: 10px;" [value]="Role.Clerk">
          <span class="mat-body-1">Clerk</span>
        </mat-radio-button>
        <mat-radio-button style="margin-right: 10px;" [value]="Role.Manager">
          <span class="mat-body-1">Manager</span>
        </mat-radio-button>
      </mat-radio-group>
      <mat-error *ngIf="
          userForm.get('role').hasError('required') && userForm.get('role').touched
        ">
        <span class="mat-caption">Role is required</span>
      </mat-error>
    </div>
  </div>
</div>

FormBuilder

duluca/lemon-mart/user/profile/profile.component.html  

<div class="stepContent">
  ...
  <div fxLayout="row" fxLayout.lt-sm="column" fxLayoutGap="10px">
    ...
    <mat-form-field appearance="outline" fxFlex="50%">
      <mat-label>E-mail</mat-label>
      <input matInput aria-label="E-mail" formControlName="email" />
      <mat-hint>Only your manager can update your e-mail.</mat-hint>
      <mat-error *ngIf="userForm.get('email').invalid">
        A valid E-mail is required
      </mat-error>
    </mat-form-field>
  </div>
  ...
</div>

FormBuilder

duluca/lemon-mart/user/profile/profile.component.html  

<mat-form-field appearance="outline" fxFlex="50%">
  <mat-label>Date of Birth</mat-label>
  <input matInput aria-label="Date of Birth" formControlName="dateOfBirth"
    [matDatepicker]="dateOfBirthPicker" />
  <mat-hint *ngIf="userForm.get('dateOfBirth').touched">
    {{ this.age }} year(s) old
  </mat-hint>
  <mat-datepicker-toggle matSuffix [for]="dateOfBirthPicker">
  </mat-datepicker-toggle>
  <mat-datepicker #dateOfBirthPicker></mat-datepicker>
  <mat-error *ngIf="userForm.get('dateOfBirth').invalid">
    Date must be with the last 100 years
  </mat-error>
</mat-form-field>

Scaling with Form Parts Reuse

 

FormBuilder

duluca/lemon-mart/user/profile/profile.component.ts  

name: this.formBuilder.group({
    first: [(user && user.name.first) || '', RequiredTextValidation],
    middle: [(user && user.name.middle) || '', OneCharValidation],
    last: [(user && user.name.last) || '', RequiredTextValidation],
 }),

FormBuilder

duluca/lemon-mart/user/profile/profile.component.html  

  <div fxLayout="row" fxLayout.lt-sm="column"
    [formGroup]="userForm.get('name')" fxLayoutGap="10px">
    <mat-form-field appearance="outline" fxFlex="40%">
      <mat-label>First Name</mat-label>
      <input matInput aria-label="First Name" formControlName="first" />
      <mat-error *ngIf="userForm.get('name').get('first').hasError('required')">
        First Name is required
      </mat-error>
      <mat-error *ngIf="userForm.get('name').get('first').hasError('minLength')">
        Must be at least 2 characters
      </mat-error>
      <mat-error *ngIf="userForm.get('name').get('first').hasError('maxLength')">
        Can't exceed 50 characters
      </mat-error>
    </mat-form-field>
    <mat-form-field appearance="outline" fxFlex="20%">
      <mat-label>MI</mat-label>
      <input matInput aria-label="Middle Initial" formControlName="middle" />
      <mat-error *ngIf="userForm.get('name').get('middle').invalid">
        Only inital
      </mat-error>
    </mat-form-field>
    <mat-form-field appearance="outline" fxFlex="40%">
      <mat-label>Last Name</mat-label>
      <input matInput aria-label="Last Name" formControlName="last" />
      <mat-error *ngIf="userForm.get('name').get('last').hasError('required')">
        Last Name is required
      </mat-error>
      <mat-error *ngIf="userForm.get('name').get('last').hasError('minLength')">
        Must be at least 2 characters
      </mat-error>
      <mat-error *ngIf="userForm.get('name').get('last').hasError('maxLength')">
        Can't exceed 50 characters
      </mat-error>
    </mat-form-field>
  </div>

Form Parts

duluca/lemon-mart/user/name-input/name-input.component.ts  

export class NameInputComponent implements OnInit {
  @Input() initialData: IName
  @Input() disable: boolean
  @Output() formReady = new EventEmitter<FormGroup>()

  formGroup: FormGroup

  constructor(private formBuilder: FormBuilder) {}

  ngOnInit() {
    this.formGroup = this.buildForm(this.initialData)
    this.formReady.emit(this.formGroup)
    if (this.disable) {
      this.formGroup.disable()
    }
  }

  ...
}

Form Parts

duluca/lemon-mart/user/name-input/name-input.component.ts  

export class NameInputComponent implements OnInit {
  ...

  buildForm(initialData?: IName): FormGroup {
    return this.formBuilder.group({
      first: [name ? name.first : '', RequiredTextValidation],
      middle: [name ? name.middle : '', OptionalTextValidation],
      last: [name ? name.last : '', RequiredTextValidation],
    })
  }
}

Form Parts

duluca/lemon-mart/user/name-input/name-input.component.html

template: `
    <form [formGroup]="formGroup">
      <div fxLayout="row" fxLayout.lt-sm="column" fxLayoutGap="10px">
        <mat-form-field appearance="outline" fxFlex="40%">
          <mat-label>First Name</mat-label>
          <input matInput aria-label="First Name" formControlName="first" required />
          <mat-error *ngIf="formGroup.get('first').hasError('required')">
            First Name is required
          </mat-error>
        </mat-form-field>
        <mat-form-field appearance="outline" fxFlex="20%">
          <mat-label>MI</mat-label>
          <input matInput aria-label="Middle" formControlName="middle" />
        </mat-form-field>
        <mat-form-field appearance="outline" fxFlex="40%">
          <mat-label>Last Name</mat-label>
          <input matInput aria-label="Last Name" formControlName="last" required />
          <mat-error *ngIf="formGroup.get('last').hasError('required')">
            Last Name is required
          </mat-error>
        </mat-form-field>
      </div>
    </form>
  `,

Form Parts

duluca/lemon-mart/user/profile/profile.component.ts  

<form [formGroup]="userForm">
  <ng-template matStepLabel>Contact Information</ng-template>
  <div class="stepContent">
    <app-name-input [initialData]="name$ | async"
      (formReady)="userForm.setControl('name', $event)">
    </app-name-input>
    ...
  </div>
</form>

Drop down with type-ahead support

Dropdown

duluca/lemon-mart/user/profile/profile.component.ts  

states$: Observable<IUSState[]>
  
buildUserForm(user?: IUser, currentUserRole = UserRole.None) {
  this.userForm = this.formBuilder.group({
    ...
    this.states$ = this.userForm
      .get('address')
      .get('state')
      .valueChanges.pipe(
        startWith(''),
        map(value => USStateFilter(value))
      )
  })
}

Dropdown

duluca/lemon-mart/user/profile/profile.component.ts  

export function USStateFilter(value: string): IUSState[] {
  return USStates.filter(state => {
    return (
      (state.code.length === 2 && state.code.toLowerCase() === value.toLowerCase()) ||
      state.name.toLowerCase().indexOf(value.toLowerCase()) === 0
    )
  })
}

Dropdown

duluca/lemon-mart/user/profile/profile.component.html

<mat-form-field appearance="outline" fxFlex="30%">
  <mat-label>State</mat-label>
  <input type="text" aria-label="State" matInput formControlName="state"
    [matAutocomplete]="stateAuto" />
  <mat-autocomplete #stateAuto="matAutocomplete">
    <mat-option *ngFor="let state of (states$ | async)" [value]="state.name">
      {{ state.name }}
    </mat-option>
  </mat-autocomplete>
  <mat-error *ngIf="userForm.get('address').get('state').hasError('required')">
    State is required
  </mat-error>
</mat-form-field>

FormArray

FormArray

duluca/lemon-mart/user/profile/profile.component.ts  

buildUserForm(user?: IUser, currentUserRole = UserRole.None) {
  this.userForm = this.formBuilder.group({
    ...
    phones: this.formBuilder.array(this.buildPhoneArray(user ? user.phones : [])),
  })
}
private buildPhoneArray(phones: IPhone[]) {
  const groups = []

  if (!phones || (phones && phones.length === 0)) {
    groups.push(this.buildPhoneFormControl(1))
  } else {
    phones.forEach(p => {
      groups.push(this.buildPhoneFormControl(p.id, p.type, p.number))
    })
  }
  return groups
}

FormArray

duluca/lemon-mart/user/profile/profile.component.ts  

private buildPhoneFormControl(id, type?: string, phoneNumber?: string) {
  return this.formBuilder.group({
    id: [id],
    type: [type || '', Validators.required],
    number: [phoneNumber || '', USAPhoneNumberValidation],
  })
}

addPhone() {
  this.phonesArray.push(this.buildPhoneFormControl(this.phonesArray.value.length + 1))
}

get phonesArray(): FormArray {
  return this.userForm.get('phones') as FormArray
}

FormArray

duluca/lemon-mart/user/profile/profile.component.html

<mat-list formArrayName="phones">
  <h2 mat-subheader>Phone Number(s)
    <button mat-button (click)="this.addPhone()">
      <mat-icon>add</mat-icon>Add Phone
    </button>
  </h2>
  <mat-list-item style="margin-top: 36px;"
    *ngFor="let position of this.phonesArray.controls; let i = index" 
    [formGroupName]="i">
    ...
    <button fxFlex="33px" mat-icon-button
      (click)="this.phonesArray.removeAt(i)">
      <mat-icon>close</mat-icon>
    </button>
  </mat-list-item>
</mat-list>

FormArray

FormArray

duluca/lemon-mart/user/profile/profile.component.ts  

PhoneTypes = $enum(PhoneType).getKeys()

FormArray

duluca/lemon-mart/user/profile/profile.component.html

<mat-form-field appearance="outline" fxFlex="100px">
  <mat-label>Type</mat-label>
  <mat-select formControlName="type">
    <mat-option *ngFor="let type of this.PhoneTypes" [value]="type">
      {{ type }}
    </mat-option>
  </mat-select>
</mat-form-field>

ngx-mask

ngx-mask

 

duluca/lemon-mart/user/user.module.ts  

@NgModule({
  imports: [
    ...
    NgxMaskModule.forChild({ showMaskTyped: true, showTemplate: true }),
  ]
})

ngx-mask

 

duluca/lemon-mart/user/profile/profile.component.ts  

<mat-form-field appearance="outline" fxFlex fxFlexOffset="10px">
  <mat-label>Number</mat-label>
  <input matInput type="text" formControlName="number" 
    mask="(000) 000-0000" prefix="+1" />
  <mat-error *ngIf="this.phonesArray.controls[i].invalid">
    A valid phone number is required
  </mat-error>
</mat-form-field>

ControlValueAccessor

The Control Value Accessor | Jennifer Wadella

ControlValueAccessor

tehfedaykin/galaxy-rating-app/star-rater.component.html

<i>{{displayText}}</i>
<div class="stars" [ngClass]="{'disabled': disabled}"> 
  <ng-container *ngFor="let star of ratings" >
    <svg title="{{star.text}}" 
      height="25" width="23" class="star rating" [ngClass]="{'selected': star.stars <= _value}" 
      (mouseover)="displayText = !disabled ? star.text : ''" 
      (mouseout)="displayText = ratingText ? ratingText : ''" 
      (click)="setRating(star)">
      <polygon points="9.9, 1.1, 3.3, 21.78, 19.8, 8.58, 0, 8.58, 16.5, 21.78" style="fill-rule:nonzero;"/>
    </svg>
  </ng-container>
</div>

ControlValueAccessor

tehfedaykin/galaxy-rating-app/star-rater.component.ts

export class StarRaterComponent implements ControlValueAccessor {
  public ratings = ...
  public disabled: boolean;
  public ratingText: string;
  public _value: number;
  writeValue(val) {
    this._value = val;
  }
  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }
  setRating(star: any) {
    if(!this.disabled) {
      this._value = star.stars;
      this.ratingText = star.text
      this.onChanged(star.stars);
      this.onTouched();
    }
  }
}
  1. FormControl
  2. Validations Reuse
  3. FormBuilder with Stepper
  4. Scaling with Form Parts Reuse
  5. Drop down with type-ahead support
  6. Form Arrays
  7. Ngx-Mask
  8. ControlValueAccessor
 
  • User Controls vs Components
  • Monolithic architecture
  • No routing
  • No dynamics
  • Disciplined form builders
  • Keep it functional, composable
  • Watch for the developer experience
  • Robust server-side error validation
 

⛴ 👈 or 👉 😣 🤷

Lemon

Mart