Angular: Applying multiple filters simultaneously to an array of objects using Pipes

Angular&NodeEnthusiast
10 min readJan 7, 2025

--

Filtering and Sorting is a basic requirement of any web application. Being a front-end developer, filtering based on single/multiple parameters and sorting based on single/multiple parameters is a must to know concept.

Below is a story which demonstrates a reusable solution for sorting based on single/multiple parameters.

In this story we will focus on the filtering part. Below is a short demo of our application. We are filtering a list of 200 ToDo items by 10 users based on the userId of the user and/or the title of the ToDo item. We will be using a pipe to filter out the data based on multiple filters.

When filtering the data based on multiple conditions, we will be checking the below points inside the pipe:

  1. Have none of the filter values changed ? If yes, return the array of objects unchanged back to the component.
  2. Has atleast 1 filter value changed ? If yes, then look for the objects inside the array that match the changed filter, keeping in mind that the other filters still remain unchanged.

I. AppComponent Template: The template below is exactly what you see in the demo. There is a <table> that displays the list of 200 ToDo items and we have a ToDoFilterComponent with selector: <to-do-filter> which displays the 2 <input> boxes on top of the table.

Lets list down some more observations:

=>todos$ is an observable which emits the array of 200 ToDo Items. We have subscribed to this observable using an async pipe, iterated through it and displayed each ToDo Item.

<table *ngIf=”todos$|async as todoList”>

=>Looking at each ToDo item, it models the below interface. We will be filtering the 200 ToDo items based on userId and/or title fields.

export interface ToDo {
userId: number;
id: number;
title: string;
completed: boolean;
}

=>The next question here is, how are we going to filter ? Pipes ! . As you can see in the below lines of code, we have used a Pipe named DataFilterPipe with selector: dataFilter. We are passing 2 inputs to this pipe: array of 200 ToDo items and the details of the filters to be applied on this 200 ToDo items. The filter details are stored in a property: filterCategories. We shall see shortly, how we have defined it and how it is used inside the pipe.

<tr *ngFor=”let todo of todoList | dataFilter:filterCategories;let count=count”>
<td>{{todo.userId}}</td>
<td>{{todo.title}}</td>
<td>{{todo.completed}}</td>
</tr>

=>Finally, coming to the ToDoFilterComponent with selector: <to-do-filter>. To this component, we are passing the filter details stored in the property: filterCategories. When any of the filter value changes, filterSelection event is triggered from the ToDoFilterComponent. When the event is triggered, the filterSelection() is called in the AppComponent.

<to-do-filter [filterCategories]=”filterCategories” (filterSelection)=”filterSelection($event)”></to-do-filter>

II. AppComponent Class

=> Inside the ngOnInit lifecycle hook , we are fetching the 200 ToDo items from the ToDoService.

=> The property filterCategories is an array of objects. Each object defines a filter and how it should be configured. We have defined 2 filters with labels: “userId” and “title” filter to filter the ToDo items based on the “userId” field and “title” field respectively.

filterCategories: FilterCategoryModel[] = [
{
label: ‘userId’,
type: ‘number’,
value: null,
exactMatch: true,
defaultValue: null,
},
{
label: ‘title’,
type: ‘string’,
value: ‘’,
exactMatch: false,
defaultValue: ‘’,
},
];

Each object in the filterCategories array models the below interface FilterCategoryModel.

export type FilterValueOrTypes = string | number | boolean | null;

export interface FilterCategoryModel {
label: string;
type: FilterValueOrTypes;
value: FilterValueOrTypes;
defaultValue: FilterValueOrTypes;
exactMatch?: boolean;
}

In this interface, the fields are quite self-explanatory.

label: It defines the name of the filter. This will match the field name of the ToDo item, whose value, the filter is supposed to use for comparison.

type: It defines the type of filter. It could be string/number/boolean/null. Based on the value of this field, we have decided HTML element to be used as a filter in the ToDoFilterComponent. For example, for type “string”, we have used <input type=”text”> and for type “number”, we have used <input type=”number”>. If we had to add another type “boolean”, we could add radio buttons or checkboxes as a filter.

value : It defines the value of the filter.

defaultValue: It defines the default value of the filter. It equals the value field initially when the application loads.

exactMatch: It decides whether the value of a field in the ToDo item should be exactly/partially matched with the value field of the filter category.

=> The filterSelection() below is called when the filterSelection event is triggered from the ToDoFilterComponent. The event is triggered when the value of any of the filter category changes. In the method, we are just updating the filterCategories property in the AppComponent with the latest updates received from the ToDoFilterComponent.

filterSelection(categories: FilterCategoryModel[]) {
this.filterCategories = JSON.parse(JSON.stringify(categories));
}

III. ToDoFilterComponent Class:

Its a very simple component. As we already know, it accepts the filterCategories as input from the AppComponent and whenever any filter changes, we send the updated value of filterCategories back to the AppComponent via the @Output filterSelection.

Whenever the value of any filter category changes, the filterChanged() is called, where we are triggering the filterSelection event, passing the updated filterCategories as argument back to the AppComponent.

IV. DataFilterPipe

The pipe receives the array of 200 ToDo items and filterCategories. I would not say this is a reusable pipe, because although the logic is reusable, we have hardcoded the interfaces specific to the ToDo items. Towards the end of the story, will demonstrate a possible way you can make pipes reusable.

=>The transform() accepts the 200 ToDo items and the filter categories as argument.

transform(todos: ToDo[], categories: FilterCategoryModel[]): ToDo[] 
{
//logic comes here
}

=>Beginning with the below piece of code:

const allDefaults = categories.every(
(category) => category.value === category.defaultValue
);
//if none of the filters have been modified, return the input data unchanged
if (allDefaults) {
return todos;
}
//filtered list is returned only if 1 or more filters changed and there is data matching the filters
return filteredList.length ? filteredList : [];

Lets look at the first condition: If the value of all the filters remains unchanged or in other words, the value field of the filter is still the same as the defaultValue field of the filter. In this scenario, we return the 200 ToDo items unchanged.

const allDefaults = categories.every(
(category) => category.value === category.defaultValue
);
//if none of the filters have been modified, return the input data unchanged
if (allDefaults) {
return todos;
}

Below are the 2 use-cases for this condition:

  1. When the application fetches the 200 ToDo items on load, value field of all the filters will be same as the defaultValue field. Hence you can see all the 200 ToDo items in the table.

2. Also, when you change the value field of all the filters back to the defaultValue field, you can see all the 200 ToDo items in the table.

The next condition is when the value field of atleast 1 filter changes. To take care of this condition ,we have another variable: filteredList.

let filteredList: ToDo[] = [];

If the filteredList is not empty, we can be sure that the value field of atleast 1 filter category has changed and is not the same as the defaultValue field and there is atleast 1 table record that matches the filter category’s new value field.

In that case, the transform() returns the filteredList instead of the original 200 ToDo items back to the AppComponent for rendering in the table.

//filtered list is returned only if 1 or more filters changed and there is data matching the filters
return filteredList.length ? filteredList : [];

If the filteredList is empty, either the value field of the filter has not changed OR none of the table records match the new value field of the filter category. In this scenario, as you see above, the transform() returns an empty array back to the AppComponent.

=>Moving to the next piece of code, where we check how we are updating the value of the filteredList.

let conditions: boolean[] = [];

filteredList = todos.reduce((acc: ToDo[], curr: any) => {

conditions = categories.reduce(
(filteracc: boolean[], category: FilterCategoryModel) => {
const categoryLabel: string = category.label;
if (category.value !== category.defaultValue) {
//check if filter has changed
if (category.exactMatch) {
//if filter has changed, check if looking for exact match
filteracc.push(curr[categoryLabel] === category.value);
} else {
//not exact match
filteracc.push(curr[categoryLabel].includes(category.value));
}
} else {
//filter not changed
filteracc.push(true);
}
return filteracc;
},[]);

if (conditions.every((condition) => condition)) {
//if the table row matches all filter, then push it to the filtered list
acc.push(curr);
}
return acc;
}, []);

This logic is very general and can be applied to any group of filters. Lets break it up to understand it easily.

The filteredList array is assigned the output of the reduce() applied to the array of 200 ToDo items stored in todos. The main purpose of this reduce() is to take each ToDo item stored in the curr parameter and check if it matches the value field of each filter category. The final value of the accumulator:acc parameter will contain the list of ToDo items which match all the filter categories.

filteredList = todos.reduce((acc: ToDo[], curr: any) => {
//outer reduce logic will come here
return acc;
},[])

Inside this reduce(), we have another inner reduce(), applied on the filter categories, received as the 2nd input to the pipe.

conditions = categories.reduce((filteracc: boolean[], category: FilterCategoryModel) => {
// inner reduce logic comes here
return filteracc;
},[])

The output of this inner reduce() is stored in the accumulator: filteracc and is assigned to a variable conditions. The variable conditions is a boolean array.

let conditions: boolean[] = [];

The purpose of this inner reduce() is to take each filter category and compare it with the ToDo item from the outer reduce(). The value of the conditions array, will contain an array of boolean values. If all values are true, we can say that the ToDo item matches all the filter categories.

Thus based on the value of this conditions array, we will come with a conclusion, whether a ToDo item must be included in the filteredList or not.

The logic inside this inner reduce() is our main logic.

conditions = categories.reduce((filteracc: boolean[], category: FilterCategoryModel) => {
const categoryLabel: string = category.label;

if (category.value !== category.defaultValue) {
//check if filter has changed
if (category.exactMatch) {
//if filter has changed, check if looking for exact match
filteracc.push(curr[categoryLabel] === category.value);
} else {
//not exact match
filteracc.push(curr[categoryLabel].includes(category.value));
}
} else {
//filter not changed
filteracc.push(true);
}
return filteracc;
},
[]
);

Lets break this up.

  1. We are storing the label field of the filter category in a constant categoryLabel.
const categoryLabel: string = category.label;

2. Next, we are checking if the value field of the filter category has changed from its initial defaultValue field. If it has not changed, we are pushing true into the accumulator: filteracc of the inner reduce().

if (category.value !== category.defaultValue) {
//logic comes here
}
else{
//filter not changed
filteracc.push(true);
}

If it has changed, we are checking if the exactMatch field of the filter category is true or false. Based on that, we are performing an exact or partial comparison between the value field of the category and value of the corresponding ToDo item field i.e we compare the value of the “userId” filter category with the value of the “userId” field of the ToDo item and the value of the “title” filter category with the value of the “title” field of the ToDo item.

if (category.exactMatch) {
//if filter has changed, check if looking for exact match
filteracc.push(curr[categoryLabel] === category.value);
}
else {
//not exact match
filteracc.push(curr[categoryLabel].includes(category.value));
}

We are pushing the results of the comparison i.e true or false into the accumulator: filteracc.

The conditions array will now contain the final value of the accumulator function: filteracc.

If every value of the conditions array is true, we are sure that the current ToDo item in the outer reduce() matches all the filter categories. Hence we push the ToDo item stored in curr into the accumulator:acc of the outer reduce().

if (conditions.every((condition) => condition)) {
//if the table row matches all filter, then push it to the filtered list
acc.push(curr);
}

The final value of the accumulator:acc assigned to the filteredList. If the filteredList variable has atleast 1 ToDo item, the transform() will return it back to the AppComponent template for rendering. If the filteredList variable is empty, it means none of the ToDo items have matched all the filter categories and the original unfiltered array of 200 ToDo items will be returned back to the component.

V. Pipe Reusability

As I said earlier, a Pipe can reused, by defining the filtering logic inside a method within the component and passing the method as argument to the Pipe. The method will be called within the transform() of the Pipe.

Below is an example of a such a reusable pipe, which receives the original array of data and the function to be executed. We have called the function, passing the array of data as argument.

import { Pipe, PipeTransform } from ‘@angular/core’;

@Pipe({
name: ‘dataFilterReusable’,
standalone: true,
})

export class DataFilterReusablePipe implements PipeTransform {
transform(inputData: unknown[], fn: Function) {
return fn(inputData);
}}

Lets see how we have used this pipe in the AppComponent Template.

We have replaced the below line of code

<tr *ngFor=”let todo of todoList | dataFilter:filterCategories;let count=count”>

WITH

<tr *ngFor=”let todo of todoList | dataFilterReusable:filterToDos.bind(this);let count=count”>

dataFilterReusable is the pipe selector used. We have passed the method filterToDos() as argument to the pipe. This method will be called within the transform() of the pipe.

We have defined the filterToDos() within the AppComponent Class as below. The content of this method is exactly the same as the transform() in the DataFilterPipe. The difference is that the filterToDos() accepts only 1 argument.

Below is the entire working example:

--

--

Angular&NodeEnthusiast
Angular&NodeEnthusiast

Written by Angular&NodeEnthusiast

Loves Angular and Node. I wish to target issues that front end developers struggle with the most.

No responses yet