An Angular Change Detection Challenge

A tags component

Last year, Lucidchart added many new features that enable users to attach metadata to their Lucidchart documents. One of the new types of document metadata is called custom tags. Custom tags functionality allows Lucidchart users to add arbitrary tag strings to their documents and to sort and search documents based on those tags. As part of implementing this custom tags functionality in Lucidchart, I wrote code for an Angular framework component to allow users to view and edit the set of custom tags applied to a document. In writing this Angular component, I ran into an interesting challenge involving Angular’s automatic change detection. This blog post describes the challenge I faced and how I addressed it. Thinking through this challenge helped me learn more about about how Angular’s change detection system works, and now I’ll pass on to you what I learned.

In this post, so as not to obscure the relevant technical details, I will present a simplified version of the Angular component that I ended up building. The custom tags editor is basically split into two pieces: a text box in which users enter tag strings and a neighboring div to display the list of previously-entered tags. The user enters the tags as a comma-separated list of strings, and the Angular component automatically extracts the completed tags as soon as they are entered, moving those extracted tags to the display div.

The GIF below shows the desired functionality for this Angular component:

GIF showing tags being extracted as they are typed into the input box
Tags are extracted as soon as they are typed.

Instead of typing tags directly into the input box, users are also allowed to paste in text from the clipboard. If users paste text in this way, the component will immediately extract all the completed tags from the pasted content. The GIF below shows the desired functionality:

GIF showing tags being extracted when a list is pasted into the input box
Tags are extracted when they are pasted in.

Here is a simplified version of the code I wrote to implement this component in Angular:

tags.component.html


<input
  type="text"
  [value]="inputValue"
  (input)="onInput($event.target.value)"
/>
<ul>
  <li *ngFor="let tag of tags">{{tag}}</li>
</ul>

tags.component.ts


import { Component } from "@angular/core";

@Component({
  selector: "app-tags",
  templateUrl: "./tags.component.html",
  styleUrls: ["./tags.component.css"]
})
export class TagsComponent {
  inputValue = "";
  tags: string[] = [];

  constructor() {}

  onInput(value: string) {
    this.inputValue = this.extractTags(value);
  }

  private extractTags(value: string): string {
    const fields = value.split(",");
    for (let i = 0; i < fields.length - 1; i++) {
      this.tags.push(fields[i]);
    }
    return fields[fields.length - 1];
  }
}

This code seems pretty sensible at first glance. The TagsComponent binds its inputValue field to the value attribute of the <input> HTML element and sets up the onInput method to handle the input event from the <input> element. Whenever the input event fires, the onInput method extracts all the tags from the newly-emitted value and sets inputValue to the string that remains once all the tags are gone.

A lurking bug

This code works well for most use cases, but it does have a bug that took a skilled quality assurance professional to find. The bug is revealed when the user pastes in some text that ends with a comma.

GIF showing tags being added to the extracted list but also left in the input text box when a pasted string ends in a comma
When the pasted string ends in a comma, the tags are extracted to the display list, but the comma-terminated string remains in the input text box.

When the string "bar,baz," (note the trailing comma) is pasted into the input text box, the tags “bar” and “baz” are both added to the tags list as expected, but the input text box still contains the string "bar,baz,". What’s going on here? The onInput method was supposed to set inputValue to the remaining string after the tags were removed, and in this case the remaining string should be the empty string. So, if inputValue is the empty string and inputValue is bound to the <input> element value attribute, then why isn’t the <input> element showing an empty string for its contents?

The diagnosis

To explain the problem, we have to think carefully about how Angular’s change detection system works. In the beginning, before the user enters any data, the inputValue string is empty and it is bound to the value attribute of the <input> element. When Angular first creates this component, it makes a note of this binding and records the fact that the empty string is the bound value at that time.

Then, when the user pastes the text into the text box, the input event is fired, and that causes the onInput method to run. The onInput method processes the string it received, removes the tags (in this case those are “bar” and “baz”) and then sets inputValue to the remaining string (in this case that’s the empty string).

When all that event-triggered code has run, Angular knows that some of the UI-bound data could have changed, so Angular shifts into change-detection mode. As part of its change detection, Angular looks at the binding of the inputValue to the value attribute of the <input> element. Angular sees that the current content of inputValue is the empty string, and it remembers that the last time it checked this binding the content of inputValue was also the empty string, so Angular concludes that nothing has changed and that it doesn’t need to update the UI of the <input> element. But the <input> element is still displaying the string "bar,baz,", so, in fact, it does need to be updated to show the empty string instead.

Why does this only happen with pasting?

When we enter tags one character at a time, Angular performs change detection after every character is entered. So, if we type in “foo”, the Angular binding records for the <input> element value attribute will follow this sequence: "", "f", "fo", "foo". Then when we type a comma, the tag “foo” will be extracted and inputValue will be set to the empty string. When Angular does change detection, it will see that the value of inputValue used to be "foo", but it has now changed to the empty string, and Angular will update the UI of the <input> element.

Actually, we can trigger this bug without using copy-paste. Typing a single comma into an empty input box will also cause a problem. An empty-string tag will be extracted and the comma will remain in the input box. As you keep typing more commas, things quickly get out of hand.

The fix

I chose to handle this problem by doing two separate updates to inputValue in the onInput method and by running a manual change detection between the two updates.


 constructor(
      private changeDetectorRef: ChangeDetectorRef,
  ) {}

  onInput(value: string) {
    // Let Angular know that the value has changed.
    this.inputValue = value;
    this.changeDetectorRef.detectChanges();

    // Tell Angular the final value we want to display.
    this.inputValue = this.extractTags(value);
  }

The first update sets inputValue to the string that is actually being displayed in the <input> element ("bar,baz," in our example). We need Angular to take note of this inputValue setting so that when we next update it to the final value we want displayed, Angular will realize that it needs to redraw the UI. Angular checks value bindings during change detection, but change detection usually only runs after all the code triggered by an event has finished. In this case we want the change detection to run before our event-triggered code is finished, so we manually call ChangeDetectorRef.detectChanges(). This call forces Angular to update its bindings, and, in particular, it forces Angular to take note of the value we have just set for inputValue.

Once Angular is notified of the value that is actually being displayed in the <input> element, our code can do the work of extracting the tags and updating that display value. This work is done in the last line of onInput. When onInput finishes, all our event-triggered code is done and Angular kicks off its regularly-scheduled change detection run. At this point, if inputValue is storing something other than the string emitted by the input event of the <input> element (because tags have been extracted), Angular will know about it and will update the <input> element UI.

The moral

The bug in my original code was caused by a pattern that seems to come up often in Angular component designs, so it is worth generalizing that pattern here.

  1. A parent and child component cooperate to manage some value.
  2. The value can be changed in the child component, and when it is, the child emits the new value to the parent as an @Output.
  3. On receiving a new value from the child, the parent does some other processing of the value and passes the newly-processed value back to the child as an @Input.

Any time this pattern occurs, there is a risk that the processed value passed from the parent to the child is equal to the last value that was passed from the parent to the child. In all of these cases, to prevent the child from displaying the unprocessed value, we can first have the parent pass the unprocessed value to the child, then manually run change detection, and finally have the parent send the processed value to the child.

Working through this bug really helped me to get a better grasp on how Angular’s automatic change detection operates, and I hope this example was interesting to you too. In my experience, Angular’s automatic change detection does a very good job in most scenarios, but as we have seen, there are some cases where we as developers must take manual control of the change detection schedule in order to prevent bugs.

No Comments, Be The First!

Your email address will not be published.