5 May

Watching for changes with Reactive Forms in Angular4

Problem

On a recent project working with Angular 4 and reactive forms a need came up to allow child components ( which were driven off off their own child form groups) be be able to detect value changes to other child components. From these changes we needed to apply rules and detect validity of the values.

Now I know your thinking

So your first response is probably duh thats what valueChange subscriptions are for, and you are totally correct. What started to happen though was a huge duplication of code across many components for not only detecting changes (valueChanges) but also detecting the validity of those changes (statusChanges). What we needed was a consolidated way to detect changes and validity across the entire parent form group and be able to deliver those changes to any sub components.

Enter the watcher service

With the help of RXjs Observables we were able to provide a single watch point that any component in the entire application could subscribe to to get any changes from any component. This service would deliver not only value changes but validity status changes as well. GoodBye code duplication I’m staying dry in this code storm.

Service Code

import {Injectable} from '@angular/core';
import {AbstractControl, FormControl, FormGroup} from '@angular/forms';
import {BehaviorSubject} from 'rxjs/BehaviorSubject';
import {Observable} from 'rxjs/Observable';
import {Subscription} from "rxjs/Rx";

declare let _;

export interface FormGroupChange {
  path: string;
  name: string;
  control: FormControl;
}

/*
 Allows you to subscribe to value and status changes for fields in a FormGroup. Events are debounced to avoid too many
 simultaneous calls, and are emitted even when the validity of a field updates because of dependencies on other fields.

 Usage:

 let subscription = this.watchFormGroupService.watch(this.formGroup, ['personalInformation.firstName', 'personalInformation.lastName'])
   .subscribe((data: FormGroupChange) => {
      // ...
   });

 // Don't forget to unsubscribe! (eg. in ngOnDestroy)
 subscription.unsubscribe();
 */

@Injectable()
export class WatchFormGroupService {

  private MAX_CHECK_COUNT = 5;

    public watch(formGroup: FormGroup, paths: string[], debounce = 400): Observable<FormGroupChange> {
    return Observable.create(observer => {
      let internalSubs = [];
      paths.map(path => {
        let control = formGroup.root.get(path);
        if(!control) {
          let checkCount = 0
          // lets give angular some time to finalize the form groups and make them available. This happens due to race conditions with when different components can 
          // load and when watchers are setup from other components.
          let checkAgainInterval = setInterval(() => {
            let control = formGroup.root.get(path);
            if(control) {
              clearInterval(checkAgainInterval);
              let eventData: FormGroupChange = {
                path: path,
                name: _.last(path.split('.')),
                control: control
              };
              let subject = new BehaviorSubject(eventData);
              internalSubs.push(Observable.merge(...[control.valueChanges, control.statusChanges, subject]).debounceTime(debounce).map(data => {
                observer.next(eventData);
              }).subscribe());
            }
            if(checkCount >= this.MAX_CHECK_COUNT) {
              console.warn("NO WATCHER PATH MATCH", path);
              clearInterval(checkAgainInterval);
            }
            checkCount++;
          }, 1000);
        } else {
          let eventData: FormGroupChange = {
            path: path,
            name: _.last(path.split('.')),
            control: control
          };
          let subject = new BehaviorSubject(eventData);
          internalSubs.push(Observable.merge(...[control.valueChanges, control.statusChanges, subject]).debounceTime(debounce).map(data => {
            observer.next(eventData);
          }).subscribe());
        }
      });

      // Provide a way of canceling and disposing the interval resource
      return function unsubscribe() {
        _.forEach(internalSubs, sub => {
          sub.unsubscribe();
        });
      };
    });
  }

}

Now one caveat to this was we need to know which child component and form control to watch on. With no other real way around it we agreed that using the dot notation to a component and field as a string while is ‘hard coded’ was the best balance between ease of use and pseudo coupling of components. Coupling in this case i feel is loosely said, because if the component doesn’t exist in the view, but its subscribed to, nothing will be broadcasted anyway.

So how do we use this thing

In any of your components you simply inject the WatchFormGroupService and watch on an array of fields.

NOTE: registration must be in the AfterViewInit or later, so all the form groups have time to build and setup from the OnInit event. This is important because when listening across all components we do not know when the form group will be ready from initialization.

What is great about the WatchFormGroupService is that it can work with many first class form group citizens (meaning you can watch different un-related form groups from the same application).

 ngAfterViewInit() {
    this.watcherSubscription = this.watchFormGroupService.watch(this.formGroup, [
      'personalInformation.mailingAddress.address1',
      'personalInformation.mailingAddress.address2',
      'personalInformation.mailingAddress.address3',
      'personalInformation.mailingAddress.zipcode',
      'personalInformation.mailingAddress.state',
      'personalInformation.mailingAddress.city'
    ]).subscribe(data => {
      if(data.control.value.isValid){
         this.formData[data.name] = data.control.value;
         this.formGroup.get("childInformation")
           .get('my.address').get(data.name)
           .patchValue(data.control.value);
        }
      }
    });
  }

In this example we are listening for address changes in the personalInformation component and then updating "my" component with the new values, but only if the value coming in is valid. While there would be more logic around when to update the value this code at least shows what is possible with the WatchFormGroupService

Cleanup

One thing to call out as with using any RSjs Observable, make sure you clean up your subscriptions. In OnDestroy of the watching component unsubscribe from the watcher service

  ngOnDestroy() {
    this.watcherSubscription.unsubscribe();
  }

I want to give a shout out to Alex Brombal who I collaborated with on the concept. He was the lucky one who got to write the WatchFormGroupService.

Check this out in action on Plunkr

UPDATE

Since I first published this a couple changes have been made to the WatchFormGroupService. We needed a way for subscribers to get values on the initial subscribe from from the service. This would be in the case the formGroup was loaded from database data, and a valueChange event has not fired yet. To accomplish this we added in a BehaviorSubject and loaded it with the initial value from the formControl.

  public watch(formGroup: FormGroup, paths: string[], debounce = 400): Observable<FormGroupChange> {
    return Observable.merge(...paths.map(path => {

      let control = formGroup.root.get(path);
      if (!control) {
        console.warn("NO WATCHER PATH MATCH", path);
        return Observable.empty();
      }
      let eventData = {
        path: path,
        name: _.last(path.split('.')),
        control: control
      };
      let subject = new BehaviorSubject(eventData);
      return Observable.merge(...[control.valueChanges, control.statusChanges, subject]).debounceTime(debounce).map(data => eventData);
    }));
  }