Using A Progressive-Search Optimization When Filtering Arrays In Angular 10.1.6
The other day, I looked at a search optimization in Angular 10 in which I use a single, pre-compiled keyword value as my search target. That optimization allows me to search across an aggregation of values with a single operation. Today, I wanted to follow-up with another search optimization that I enjoy: using a progressive-search to filter on an increasingly small number of records in Angular 10.1.6.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
The idea with a progressive-search in Angular is that with every key-stroke, we can make some assumptions about the area over which we need to operate. Imagine that we're searching a list of Friends and the user types-in the following set of characters in a search input:
M
Ma
Mar
Mari
Maria
This search is "progressive", in that each character builds on top of the set of characters already entered: Mari
is really just the Mar
search followed by an additional i
character constraint. Because of this, we know that the Mari
search operation has already been limited by the Mar
prefix. And, as such, we can apply the current search operation to the set of intermediary results that the Mar
operation already produced.
What this means is that each subsequent search operation in a progressive-search is operating on an increasingly smaller set of records. Which, depending on the complexity of the page, could lead to a performance improvement.
To see this in action, I've put together a simple demo in which we can search over a set of Friends using a simple keyword search. And, with every key-input, we're going to filter the list using the applySearchFilter()
method; and, output the number of records that are being searched in each given operation:
// Import the core angular services.
import { Component } from "@angular/core";
// Import the application components and services.
import { Friend } from "./friends";
import { friends } from "./friends";
// ----------------------------------------------------------------------------------- //
// ----------------------------------------------------------------------------------- //
interface SearchResult {
friend: Friend;
sort: string;
keywords: string;
}
var USE_FILTER_OPTIMIZATION = true;
@Component({
selector: "app-root",
styleUrls: [ "./app.component.less" ],
templateUrl: "./app.component.html"
})
export class AppComponent {
public allSearchResults!: SearchResult[];
public filteredSearchResults!: SearchResult[];
public searchFilter: string;
private friends: Friend[];
private previousSearchFilter: string;
// I initialize the app component.
constructor() {
this.friends = friends;
this.searchFilter = "";
this.previousSearchFilter = "";
this.setAllSearchResults();
this.setFilteredSearchResults();
}
// ---
// PUBLIC METHODS.
// ---
// I update the filtered search results to use the given filter.
public applySearchFilter( searchFilter: string ) : void {
this.searchFilter = searchFilter.trim();
this.setFilteredSearchResults( USE_FILTER_OPTIMIZATION );
// Now that we've applied the filtering for the given search filter, let's store
// the given filter as the previous filter so that we can attempt to optimize
// subsequent filter operations that build on top of the current one.
this.previousSearchFilter = this.searchFilter;
}
// ---
// PRIVATE METHODS.
// ---
// I setup the all-results collection based on the current friends.
private setAllSearchResults() : void {
this.allSearchResults = this.friends.map(
( friend ) => {
return({
friend: friend,
sort: friend.name.toLowerCase(),
keywords: friend.name.toLowerCase()
});
}
);
this.allSearchResults.sort(
( a, b ) => {
return( a.sort.localeCompare( b.sort ) );
}
);
}
// I setup the filtered-results collection based on the current all-results.
private setFilteredSearchResults( useFilterOptimization: boolean = false ) : void {
var normalizedFilter = this.searchFilter.toLowerCase();
if ( normalizedFilter ) {
// PERFORMANCE OPTIMIZATION: If the current filter is just an ADDITION to the
// previous filter, then we can improve performance of the search by using
// the FILTERED SEARCH RESULTS as our target set. This means that as the user
// types "forward", each operation will operate over an increasingly small
// number of records.
var canUseFilterOptization = (
useFilterOptimization &&
this.previousSearchFilter &&
this.searchFilter.startsWith( this.previousSearchFilter )
);
var intermediaryResults = ( canUseFilterOptization )
? this.filteredSearchResults
: this.allSearchResults
;
// Let's output some debugging information about which list we are searching
// so that we can see how the progressive-search filtering affects the
// surface area of the search operation.
console.group( "Searching List" );
console.log( "Keywords:", normalizedFilter );
console.log( "Record Count:", intermediaryResults.length );
console.groupEnd();
this.filteredSearchResults = intermediaryResults.filter(
( result ) => {
return( result.keywords.includes( normalizedFilter ) );
}
);
} else {
// If there is no search-filter, then we can just reset the filtered-results
// to be the all-results collection.
this.filteredSearchResults = this.allSearchResults;
}
}
}
As you can see, in the setFilteredSearchResults()
method, which is calculating the set of Friends to output, we're looking to see if the conditions are right for optimization. And, if the conditions are right, we're using the filteredSearchResults
collection - not the allSearchResults
collection - as the target of our search.
Now, if we go into the browser and search for Maria
, we can see that the size of the target collection is shrinking in size:
As you can see, by the time we get to the last letter, our search operation is only filtering on three records. Keep in mind that we have a list of 100 friends; so, this is a much smaller search target.
Of course, this search optimization only works if our search is progressive. If we were to start deleting characters from our search, we can no longer use this technique:
As you can see, as we delete characters, each subsequent search has to search over the entire 100-item list of Friends. This is because we can no longer make any assumptions about how the current search builds on top of the previous search results.
What I love about this technique is that it's a really easy search optimization to put in place. It's not like we have to change our whole approach just to squeeze out minor performance improvements - it's literally just a switch on which Array we are targeting in each application of our filter.
Want to use code from this post? Check out the license.
Reader Comments
I use this type of filtering a lot. I use vanilla JS and fetch though. I query a small dataset of Ch. 7 bankruptcy trustees, It's only about 1200 or so entries. Not sure what your backend code looks like but at the top of the function I do a query for all the Trustees at once and cache the data in the cfquery for 5 minutes. I use query.each or query.filter to do my filtering work. Seems to make the whole process a little perkier.
@Hugh,
I haven't played with
fetch
yet; but mostly because I'm in a brown-field app where there is an established AJAX layer. I really like the idea of caching things for a bit - I really wish we explored more caching on the server. Right now, we do in-memory caching in the JavaScript, which definitely helps. But, the code is so old that there are many dragons that live in the way the cache is managed. Much to clean up!