Evolving Angular to remember unsaved changes

Evolving Angular to remember unsaved changes

This article builds on the prior series showing how to create an application using Angular 2 or later, and leverage ngrx store for application state.  The last article in the series completed a functional application using NgRx to communicate state changes between components and recall current state when switching back to a given state.  This article will extend the project with new features to remember the search criteria without mutating the stored state unintentionally.

Overview of the app structure

The structure of this app includes a top level person component which is a tabbed interface with the first tab for searching, the default tab when the app is loaded.  When search is initiated the results appear on the search tab below the search form.  When a result is clicked on the detail view appears in another tab.  The tab strip also includes a plus button to add a new item which shows in a new tab.

Duplicate tabs for the same item are not allowed to avoid confusing the user, but you can have as many add new item tabs as you wish.  Upon save of an item the presentation of that item in the search results should also update, and further clicks on that result should not get confused by opening a new tab if the item is still open from the initial tab, but rather that original tab should activate instead.

The application consists of more than one routed state in a menu at the top of the page (think of them as separate pages).  If you navigate away from one routed state to another and return again the same tab should be open as if you had never left.  This is a Single Page Application (SPA) so you don’t really ever navigate away from the page.  If you wanted to accomplish the same thing with a multi-page application you may want to also store changed state in isolated storage, which is beyond the scope of this article.

New requirement: remember my search

In the prior app version when you left the people list to look at the location search and then came back to people search, your results were still there since they were loaded from state, but the criteria itself would be empty.  The new feature in this article is making the search form populate with the last search whether or not it was submitted.

Step 1: load search from the state

In the prior version of the app the search form component initialized with a new instance of search criteria.  The first step of this solution is simple, just create an observable into the search criteria state.

// app\person\search\person-search.component.js
export class PersonSearchComponent {
    public criteria$: Observable<PersonCriteria>;
    //...

    constructor(private _service: PersonService, private _store: Store<AppState>) {
        this.criteria$ = _store.select((s: AppState) => s.person.criteria);
        //...
    }
    //...
}

In respect of the smart/view pattern, the state is loaded here instead of in the form so that we can keep the form component simple, unaware of where the criteria is persisted.  This high level search component is our ‘smart’ component that orchestrates the various parts of search, the form and the results.  Passing this criteria into the search form is a relatively simple task.

// app\person\search\person-search.component.html
 <person-search-form
   [criteria]="criteria$ | async"
   (criteriaChange)="criteriaChange($event);"
   (submitSearch)="criteriaSubmitted($event);"
   (resetSearch)="criteriaReset($event);">
 </person-search-form> 
//...

The use of the async pipe is very important here to ensure the value is passed down, and not the initially empty observable. Forgetting the async pipe will cause errors and prevent the form from loading. Receiving this criteria in the search form component is now as easy as creating in input property in the component.  Note that this component does not reference any observables, since by the time the value is passed into this component, it is a simple value.

// app\person\search\person-search-form.component.js
export class PersonSearchFormComponent implements AfterViewInit {
 @Input() criteria: PersonCriteria;
 @Output() submitSearch: EventEmitter<PersonCriteria> = new EventEmitter<PersonCriteria>();
 @Output() resetSearch: EventEmitter<void> = new EventEmitter<void>();
 @Output() criteriaChange: EventEmitter<PersonCriteria> = new EventEmitter<PersonCriteria>();
 @ViewChild('ngForm') ngForm: NgForm;
 
 ngAfterViewInit() {
 this.ngForm.form.valueChanges.subscribe(this.criteriaChange);
 }
}

The criteria can now be used in the search form as usual, except for one potential problem.

Don’t modify state outside of reducers

The ngrx state of an application should only be modified using the reducer function(s) of your app, which is called by the ngrx dispatcher.  Ngrx handles the ‘time-machine’ ie. undo-redo capability by keeping a hold on all the prior states.  When your reducer is called, as shown in the prior article, you always return a new state instance with the changes applied.  When you pull an item from the state you hold a direct reference to that state.  Ngrx detects changes to state made by your reducer function by checking of the object reference has changed, such that when you return the same state instance (even with possible changed values), it believes the action caused no state change and does not trigger any change notifications to the observers.  The advantage of the object reference check for equality is performance over checking every nested sub-property.  In a future article on ‘freeze’ we will find how to lock objects in the state from accidental modification.

The sample project search criteria form was previously bound as a 2 way ngModel binding from a criteria instance pulled directly from the store.  But since this was not pulled from the state, it was not a problem. If this continues when it is pulled from state, then when the criteria is edited the store will be changed as an unwanted side effect.  The search form needs to ensure it does not change this state from the store, and we can do this one of two ways.  We can either pass a deep clone of the item from the state down to the form, or we can use it as a one-way binding so the original state instance is not modified.  This sample will take the second approach in this case.

 // app\person\search\person-search-form.component.html
<form id="personSearchForm" #ngForm="ngForm" class="panel" 
    (submit)="submitSearch.emit(ngForm.form.value)" 
    (reset)="resetSearch.emit()">
  <div class="panel-body">
    <div class="form-inline">
      <div class="form-group">
        <input type="text" class="form-control" name="term" 
          [ngModel]="criteria.term" placeholder="Name or phone number">
      </div>
      <button class="btn btn-primary" type="submit">Search</button>
      <button class="btn btn-default" type="reset">Reset</button>
    </div>
  </div>
</form>

Notice the ngModel is a one-way binding by wrapping it with square brackets only, and not the typical “bananas in a box”, ie. [()]. This results in the underlying criteria not being modified as the form values are changed. This is what we want, but we also want to get a hold of those updated values as the form changes and submit those changes to the store. Refer back to the personSearchFormComponent and notice the ViewChild named ‘ngForm’ of type NgForm. Angular will bind this NgForm property to the changes in the form defined in the view template. Then in the AfterViewInit the changes are subscribed to, which then trigger our custom ‘criteriaChange’ event to fire each time the criteria changes.

// app\person\search\person-search-form.component.js
export class PersonSearchFormComponent implements AfterViewInit {
 //..
 @Output() criteriaChange: EventEmitter<PersonCriteria> = new EventEmitter<PersonCriteria>();
 @ViewChild('ngForm') ngForm: NgForm;
 
 ngAfterViewInit() {
 this.ngForm.form.valueChanges.subscribe(this.criteriaChange);
 }
}

The person search component subscribes to this new event emitter.

// app\person\search\person-search.component.js
export class PersonSearchComponent {
    //... see github for the full source

    public criteriaChange(criteria: PersonCriteria): void {
        this._store.dispatch(new PersonSearchCriteriaChangeAction(criteria));
    }
}
// app\person\search\person-search.component.html
 <person-search-form [criteria]="criteria$ | async"
   (criteriaChange)="criteriaChange($event);"
   (submitSearch)="criteriaSubmitted($event);"
   (resetSearch)="criteriaReset($event);">
 </person-search-form>
//...

To support this event, a new reducer action was added in the person store.

export const PersonActionTypes = {
    SEARCH_CRITERIA_CHANGE: type('[Person] Search Criteria Change'),
    //... see github for the full source
}

export function PersonReducer(state = initialState, action: PersonActions): PersonState {
    switch (action.type) {
        case PersonActionTypes.SEARCH_CRITERIA_CHANGE: {
            let changeAction = <PersonSearchCriteriaChangeAction>action;
            return tassign(state, {
                criteria: changeAction.payload || {}
            });
        }
        //... see github for the full source
    }
}

export class PersonSearchCriteriaChangeAction implements Action {
    type = PersonActionTypes.SEARCH_CRITERIA_CHANGE;
    constructor(public payload: PersonCriteria) { }
}

Now the state will be updated as you enter values in the form, and the state will be updated.

Looking Forward

There is still room for improvement in this project’s store.  What if you forget to handle values from the store properly in other places?  Is there anywhere else in the project now or in the future that will modify the state, and what safeguards can we put in place to know when that mistake is made?  For now look into ‘ngrx/freeze’, or wait for the next improvement to this project on github and the corresponding followup article.



Further Reading

Full Source code for this article series
https://github.com/michael-lang/sample-ng2-mvc

Next steps for state in this project using ngrx freeze
https://github.com/codewareio/ngrx-store-freeze

How to build a real world Angular app with ngrx store, Part I: Define the state

How to build a real world Angular app with ngrx store, Part II: Components using Store

 

 

 

About the Author

Michael Lang

Co-Founder and CTO of Watchdog Creative, business development, technology vision, and more since 2013, Developer, and Mentor since 1999.

See the about page for more details.

4 Comments