Skip to main content
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Katie Glenn
Ben Nadel at InVision In Real Life (IRL) 2019 (Phoenix, AZ) with: Katie Glenn

Using A Progressive-Search Optimization When Filtering Arrays In Angular 10.1.6

By
Published in Comments (2)

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:

A list of Friends being filtered in Angular 10.1.6.

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:

A list of Friends being filtered in Angular 10.1.6.

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

7 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.

15,848 Comments

@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!

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel