Reputation: 121
I have following input:
<input type="text" placeholder="Search for new results" (input)="constructNewGrid($event)" (keydown.backslash)="constructNewGrid($event)">
and the function
constructNewGrid(e){
// I want to wait 300ms after the last keystroke before constructing the new grid
// If the passed time is <300ms just return without doing something
// else start constructing new grid
}
I am not quite sure how to build such a condition. How should I approach this problem? I read about debounceTime in RxJS which exactly what I want but I do not use observables in the function, so: How would you build such a condition in your function?
Upvotes: 3
Views: 4471
Reputation: 8022
The simplest way to turn a DOM event into a stream is RxJS's fromEvent
creation operator.
Instead of binding (input)="function"
, you get a reference to your DOM element. Here, well call your DOM element 'searchInput'. You can call it anything.
<input type="text" placeholder="Search for new results" #searchInput>
then in your TS:
@ViewChild('searchInput') searchInput: ElementRef;
ngOnInit(){
merge(
fromEvent(searchInput, 'input'),
fromEvent(searchInput, 'keydown.backslash')
).pipe(
debounceTime(3000)
).subscribe(e => constructNewGrid(e));
}
This creates two streams and merges them together. The first stream are events from 'input' and the second stream are events from 'keydown.backslash'.
Angular provides you a FormControl that exposes a stream but also includes a lot of extras that you wouldn't get from simply binding a DOM event. It has built-in input validation and a lot of support to help you manage lots of FromControls at once (via FormGroups).
In this case, it would look something like:
<input type="text" placeholder="Search for new results" [formControl]="searchInputControl">
then in your TS:
searchInputControl = new FormControl('');
ngOnInit(){
this.searchInputControl.valueChanges.pipe(
debounceTime(3000)
).subscribe(val => constructNewGrid(val));
}
RxJS subjects are the standard way to bridge imperative code into functional streams. What you have right now is a callback function being handed values that you'd like to turn into a stream in order to process them. You can do this with a Subject.
stream = new Subject();
function callback(value){
this.stream.next(value);
}
Now you can process those values as a stream.
ngOnInit(){
this.stream.pipe(
denounceTime(3000)
)subscribe(val => process(val));
}
I this case, when your subject is destroyed by angular lifecycle events, your subscription to the subject is not cleaned up. You'll need to unsubscribe to avoid memory leaks.
In newer browsers, you don't have to worry about cleaning up your subscriptions to the DOM element/formControl
as once the DOM element or formControl
is removed, the subscriptions to that element/control are removed from memory too. In older browsers, however, this may still cause a memory leak for the DOM element subscriptions.
So, to be sure you may want to include some unsubscribe logic. There are two patterns I commonly see used.
With the first, you create one subscription object. Subscriptions can be put together so that a call to an unsubscribe()
of one Subscription may unsubscribe multiple Subscriptions. You can do this by "adding" one subscription into another. We don't do that in this example as there's just one stream.
@ViewChild('searchInput') searchInput: ElementRef;
private subscriptions: Subscription;
ngOnInit(){
subscriptions = merge(
fromEvent(searchInput, 'input'),
fromEvent(searchInput, 'keydown.backslash')
).pipe(
debounceTime(3000)
).subscribe(e => constructNewGrid(e));
}
ngOnDestory(){
subscriptions.unsubscribe();
}
With the second, you create a subject whose only job is to kill all the other streams you've created. Then every long-lived stream must include the takeUntil()
operator.
@ViewChild('searchInput') searchInput: ElementRef;
private _destroy$ = new Subject();
ngOnInit(){
merge(
fromEvent(searchInput, 'input'),
fromEvent(searchInput, 'keydown.backslash')
).pipe(
debounceTime(3000),
takeUntil(this._destroy$)
).subscribe(e => constructNewGrid(e));
}
ngOnDestory(){
this._destroy$.next();
this._destroy$.complete();
}
Which is better? That's a matter of taste. I like the second one because I read somewhere (though I can't find it now), that component lifecycle events might be exposed as streams sometime in the future. If so, the takeUntil() approach becomes much more concise as you'll no longer need to create a custom subject to handle it.
Upvotes: 3
Reputation: 71911
Observables seem to be the way to go, but the good old setTimeout
will get you a long way as well. For esthetic reasons let's first rename your input handler:
the backslash event seems a bit double, because this also triggers (input)
<input type="text" placeholder="Search for new results"
(input)="onInput(input.value)" #input>
In your component you have two choices to handle this input, either with observables or without. Let me show you first without:
export class GridComponent {
private timeout?: number;
onInput(value: string): void {
window.clearTimeout(this.timeout);
this.timeout = window.setTimeout(() => this.constructNewGrid(value), 300);
}
constructNewGrid(value: string): void {
// expensive operations
}
}
This looks easy enough, and for your use case it might be enough. But what about those cool rxjs streams people keep talking about. Well that looks like this:
export class GridComponent {
private search$ = new BehaviorSubject('');
private destroy$ = new Subject<void>();
ngOnInit(): void {
this.search$.pipe(
// debounce for 300ms
debounceTime(300),
// only emit if the value has actually changed
distinctUntilChanged(),
// unsubscribe when the provided observable emits (clean up)
takeUntil(this.destroy$)
).subscribe((search) => this.constructNewGrid(search));
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
onInput(value: string): void {
this.search$.next(value);
}
constructNewGrid(value: string): void {
// expensive operations
}
}
That looks like a lot more code for such a simple thing, and it is. So it's up to you.
If however you feel like this pattern is something you are going to use more often, you can also think about writing a directive, which would look like this:
@Directive({
selector: '[debounceInput]'
})
export class DebounceInputDirective {
@Input()
debounceTime: number = 0;
@HostListener('input', '[$event]')
onInput(event: UIEvent): void {
this.value$.next((event.target as HTMLInputElement).value);
}
private value$ = new Subject<string>();
@Output()
readonly debounceInput = this.value$.pipe(
debounce(() => timer(this.debounceTime || 0)),
distinctUntilChanged()
);
}
This you can use in your component like this:
<input type="text" placeholder="Search for new result"
(debounceInput)="onInput($event)" [debounceTime]="300">
Another way to write this directive in an even more rxjs style is:
@Directive({
selector: 'input[debounceInput]'
})
export class DebounceInputDirective {
@Input()
debounceTime: number = 0;
constructor(private el: ElementRef<HTMLInputElement>) {}
@Output()
readonly debounceInput = fromEvent(this.el.nativeElement, 'input').pipe(
debounce(() => timer(this.debounceTime)),
map(() => this.el.nativeElement.value),
distinctUntilChanged()
);
}
The good thing about using directive (and unrelated, the async
pipe), is that you do not have to worry about lingering rxjs subscriptions. These can be potential memory leaks.
But wait! There's more. You can forget all those things, and go back to the roots of typescript with angular. Decorators! How about a fancy debounce decorator on your method. Then you can leave everything as you had it before, and just add @debounce(300)
above your method:
@debounce(300)
constructNewGrid(event): void {
// ...
}
What? Really? What does this debounce decorator look like. Well, it could be as simple as this:
function debounce(debounceTime: number) {
let timeout: number;
return function (
_target: any,
_propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod: Function = descriptor.value;
descriptor.value = (...args: any[]) => {
window.clearTimeout(timeout);
timeout = window.setTimeout(() => originalMethod(...args), debounceTime);
};
return descriptor;
};
}
But this is untested code though, but it's to give you an idea as to what's all possible :)
Upvotes: 13