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 neighboringdiv
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:
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:
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. 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, theinputValue
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 toinputValue
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.- A parent and child component cooperate to manage some value.
- 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
. - 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
.
About Lucid
Lucid Software is a pioneer and leader in visual collaboration dedicated to helping teams build the future. With its products—Lucidchart, Lucidspark, and Lucidscale—teams are supported from ideation to execution and are empowered to align around a shared vision, clarify complexity, and collaborate visually, no matter where they are. Lucid is proud to serve top businesses around the world, including customers such as Google, GE, and NBC Universal, and 99% of the Fortune 500. Lucid partners with industry leaders, including Google, Atlassian, and Microsoft. Since its founding, Lucid has received numerous awards for its products, business, and workplace culture. For more information, visit lucid.co.