1. Web Design
  2. JavaScript

How to Set Up a Multi-Filter Feature with Vanilla JavaScript

Scroll to top
Read Time: 11 min

In this tutorial, we’re going to use vanilla JavaScript to implement a multi-filter feature, that allows a user to select and combine multiple different filter types.

Filtering is a commonly used feature on most user-centric websites such as e-commerce sites or blog pages. It allows a user narrow down content using specific conditions.

In a previous tutorial, we looked at how to filter data from a list using a single parameter. 

This method served as a good introduction on how to filter data in a list but most real-life cases require a more complex method of filtering.

Take a website that sells clothes. Users would require the ability to filter by color, size, product type etc. It’s also possible to want to select different filters in the same category. For example, in the above demo recreating an article page, a user might want to see all articles tagged with “JavaScript” and “CSS” as opposed to just one tag. 

Multi-Filter Demo

In this tutorial, we’re going to implement a multi-filter feature that allows a user to select and combine multiple different filter types. Here’s a look at our updated filter:

Now let’s build it!

1. Layout & Styling

We’ll be using the same layout and styling from the previous tutorial and updating our HTML to include a Categories and Level container. We’ll also include an article count element after the title for our articles and a container to display a “no results found” message. This is our updated HTML.

1
<main class="container">
2
  <div class="container__title">Categories</div>
3
  <div id="post-categories" class="filter-container"></div>
4
  <div class="container__title" >Level</div>
5
  <div id="post-level" class="filter-container"></div>
6
  <div class="container__title">
7
    Tutorials (<span id="post-count"></span>)
8
  </div>
9
  <div id="posts-container"></div>
10
  <p id="no-posts"></p>
11
</main>

2. Displaying Data With JavaScript

In a previous tutorial, we discussed scraping data from the Tuts+ author page to create a mock data object for our demo. We’ll be using the Fetch API to retrieve the scraped data stored in a Github gist.

This is what our data object looks like:

1
[
2
  {
3
    "title": "",
4
    "link": "",
5
    "image": "",
6
    "categories": [""],
7
    "level": ""
8
  },
9
  ...
10
 ]

This is the script for fetching data from the script:

1
fetch("https://gist.githubusercontent.com/jemimaabu/564beec0a30dbd7d63a90a153d2bc80b/raw/12741cce73c71c179381cc9e3b5f79988ea76a45/tutorial-levels"
2
).then(async (response) => {
3
  // handle response data

4
});

Once we’ve gotten our fetched data, we can then manipulate the data and append it on the page.

3. Appending Data to the Webpage

For each object in our fetched response, we’ll create a post div that will display the data on the page. First, let’s define our global variables.

We’ll create a postsData variable to store our fetched data.

1
let postsData = "";

Since this tutorial uses multiple filters, we’ll also need a currentFilters variable to store which filters are currently selected:

1
let currentFilters = {
2
  categories: [],
3
  level: []
4
};

Now we’ll create variables for our existing elements:

1
const postsContainer = document.querySelector("#posts-container");
2
const categoriesContainer = document.querySelector("#post-categories");
3
const levelsContainer = document.querySelector("#post-levels");
4
const postCount = document.querySelector("#post-count");
5
const noResults = document.querySelector("#no-results");

Then we’ll create a function createPost() that will handle appending a new div to the postsContainer element. In this function, we create a new div element with the classname “post” and set the innerHTML as the data we want to display.

1
const createPost = (postData) => {
2
  const { title, link, image, categories, level } = postData;
3
  const post = document.createElement("div");
4
  post.className = "post";
5
  post.innerHTML = `

6
      <a class="post-preview" href="${link}" target="_blank">

7
        <img class="post-image" src="${image}">

8
      </a>

9
      <div class="post-content">

10
        <p class="post-title">${title}</p>

11
        <div class="post-tags">

12
          ${categories
13
            .map((category) => {
14
              return '<span class="post-tag">' + category + "</span>";
15
            })
16
            .join("")}

17
        </div>

18
        <div class="post-footer">

19
          <span class="post-level">${level}</span>

20
        </div>

21
      </div>

22
  `;
23
24
  postsContainer.append(post);
25
};
Inside our post innerHTML, we use the join("") method on our categories.map() to remove the ',' symbol that’s included in every array.

Now we can update our response function to call the createPost() function once the data has been fetched and also update our postCount element:

1
fetch("https://gist.githubusercontent.com/jemimaabu/b89339c1b7e5f81f8737fb66a858b6fc/raw/cdded4a10dbc98858481b5aedbcce3f3026dc271/tutorials"
2
).then(async (response) => {
3
  postsData = await response.json();
4
  postsData.map((post) => createPost(post));
5
  postCount.innerText = postsData.length;
6
});

4. Get Filter Params from Response

Since we’re using JavaScript, we can map through our response to create a dynamic list of filter params.

We’ll write a script that sorts through the categories and level keys in each response object and returns a unique list. We can update our response object to handle getting a unique list of filter params.

One thing to note is that the categories and level keys are of different data types so they’ll need to be handled differently. Categories is an array and level is a string.

1
fetch(
2
  "https://gist.githubusercontent.com/jemimaabu/564beec0a30dbd7d63a90a153d2bc80b/raw/12741cce73c71c179381cc9e3b5f79988ea76a45/tutorial-levels"
3
).then(async (response) => {
4
  postsData = await response.json();
5
  postsData.map((post) => createPost(post));
6
  postCount.innerText = postsData.length;
7
8
  categoriesData = [
9
    ...new Set(
10
      postsData
11
        .map((post) => post.categories)
12
        .reduce((acc, curVal) => acc.concat(curVal), [])
13
    )
14
  ];
15
16
  levelData = [...new Set(postsData.map((post) => post.level))];
17
});

Breaking down the code for our categoriesData:

  • We use [... new Set] to create an array of unique values. Set returns an object of unique values and the spread syntax [...] converts the object into an array.
  • We map through the postsData to get the categories array of each post object inside the data response.
  • We use the .reduce() method to combine the categories array for each post object into one array.

Since level is a string, we don’t need to reduce it down to one array so we can just create a new array using the Set object and map method.

Once we’ve gotten our array of unique filter values from the post results, we can create a function to append each filter to the page.

We’ll create a new button element and set the innerText according to the filter value. We’ll also need to take into account what type of filter it is (either a category filter or a level filter).

Each filter button will have a click event listener set to the handleButtonClick function, which will be responsible for handling the filtering logic. We’ll also set a “data-state” attribute to handle changing the button state when clicked. 

1
const createFilter = (key, param, container) => {
2
  const filterButton = document.createElement("button");
3
  filterButton.className = "filter-button";
4
  filterButton.innerText = param;
5
  filterButton.setAttribute("data-state", "inactive");
6
  filterButton.addEventListener("click", (e) =>
7
    handleButtonClick(e, key, param, container)
8
  );
9
10
  container.append(filterButton);
11
};

createFilter takes the following params and passes them to the handleButtonClick function:

  • key: The filter key of the response object (category or level)
  • param: The corresponding value of the key (e.g. post[0].level = 'Beginner')
  • container: The container element the filter will be appended to.

The response function can now be updated to call the createFilter() function:

1
fetch(
2
  "https://gist.githubusercontent.com/jemimaabu/564beec0a30dbd7d63a90a153d2bc80b/raw/12741cce73c71c179381cc9e3b5f79988ea76a45/tutorial-levels"
3
).then(async (response) => {
4
  postsData = await response.json();
5
  postsData.map((post) => createPost(post));
6
  postCount.innerText = postsData.length;
7
8
  categoriesData = [
9
    ...new Set(
10
      postsData
11
        .map((post) => post.categories)
12
        .reduce((acc, curVal) => acc.concat(curVal), [])
13
    )
14
  ];
15
  categoriesData.map((category) =>
16
    createFilter("categories", category, categoriesContainer)
17
  );
18
19
  levelData = [...new Set(postsData.map((post) => post.level))];
20
  levelData.map((level) => createFilter("level", level, levelsContainer));
21
});

5. Handle Button Click 

Now we’ve gotten our filter buttons and initial data, we can define a function to handle filtering the data when a button is clicked. This is where the bulk of our filtering component will be handled.

We want to detect when a button has been clicked and update the button state. In this tutorial, we’ll toggle the buttons so if clicked once, the button is set to active and if clicked again, the button is set to inactive. Let’s break down how we’ll be handling the button click:

  1. If an inactive button is clicked, we add the current param to the appropriate key in our currentFilters object. For example, if the filter button Animation under Categories is clicked, our currentFilters object should look like { categories: ['Animation'], level: [] }; We’ll also update the style of the button to its active state.
  2. If the button is already active, we’ll set it to inactive and remove the parameter from the corresponding key in currentFilters. We can do this by filtering the currentFilters key.
  3. When we’ve updated the button state, we’ll handle filtering posts based on the values in the currentFilters object.

We can now define our handleButtonClick function:

1
const handleButtonClick = (e, key, param, container) => {
2
  const button = e.target;
3
  const buttonState = button.getAttribute("data-state");
4
  if (buttonState == "inactive") {
5
    button.classList.add("is-active");
6
    button.setAttribute("data-state", "active");
7
    currentFilters[key].push(param);
8
    handleFilterPosts(currentFilters);
9
  } else {
10
    button.classList.remove("is-active");
11
    button.setAttribute("data-state", "inactive");
12
    currentFilters[key] = currentFilters[key].filter((item) => item !== param);
13
    handleFilterPosts(currentFilters);
14
  }
15
};

Let’s take a look at how the currentFilters get updated based on the button state:

6. Defining Multi-filters

One thing to keep in mind when manipulating data is to avoid mutation. We want to ensure that our original array doesn’t get changed when we’re filtering.

First we’ll create a new instance of our postsData variable called filteredPosts to avoid mutating the original array.

1
let filteredPosts = [...postsData];

Now we can work on the filtering logic. Keep in mind that our postsData returns two different data types for the filters: categories is an array and level is a string.

We want to make sure our filter component works for these two different data types.

For the categories array in currentFilter, we’ll need to first we check if the array isn’t empty using filters.categories.length > 0

Now we’ll be filtering the post categories by the currentFilters categories. We want to return any post where any of the values in post[categories] are also present in currentFilters[categories].

We can do this using the .filter() and .some method.

Thesome()method tests whether at least one element in the array passes the test implemented by the provided function. - MDN

We’ll filter the post array to return any post where some of the values in post.categories are included in our currentFilters.categories. Here’s what the code looks like:

1
 if (filters.categories.length > 0) {
2
    filteredPosts = filteredPosts.filter((post) =>
3
      post.categories.some((category) => {
4
        return filters.categories.includes(category);
5
      })
6
    );
7
  }

Then we’ll pass the filteredPosts into our level filter as well. This is where the multi filter logic comes in as we only carry out the filter logic on the already filtered value.

When filtering with a string, we just need to check if the post.level value is included in our currentFilters.level array

1
  if (filters.level.length > 0) {
2
    filteredPosts = filteredPosts.filter((post) =>
3
      filters.level.includes(post.level)
4
    );
5
  }

For a more advanced filter implementation, we can compress our categories filter and level filter functions into one. 

1
  let filterKeys = Object.keys(filters);
2
3
  filterKeys.forEach((key) => {
4
    let currentKey = filters[key]
5
    if (currentKey.length <= 0) {
6
      return;
7
    }
8
9
    filteredPosts = filteredPosts.filter((post) => {
10
      let currentValue = post[key]
11
      return Array.isArray(currentValue)
12
        ? currentValue.some((val) => currentKey.includes(val))
13
        : currentKey.includes(currentValue);
14
    });
15
  });

In the above function, we:

  • Get the key values in our currentFilters object. This will return an array ["categories", "level"]
  • Map through our filterKeys array and perform an early return if the currentKey is an empty array
  • Carry out the filter function on our filteredPosts array depending on if the current filter value is an array or not.

Finally we can define our multi-filter logic:

  • Create a function handleFilterPosts() that accepts a filters param
  • Set filteredPosts by all the values in our currentFilters array.
  • We’ll also update our postCount and noResults elements based on the data in filteredPosts
  • Finally, we’ll clear all elements in posts-container and append the new filteredData to the container.
1
const handleFilterPosts = (filters) => {
2
  let filteredPosts = [...postsData];
3
  let filterKeys = Object.keys(filters);
4
5
  filterKeys.forEach((key) => {
6
    let currentKey = filters[key]
7
    if (currentKey.length <= 0) {
8
      return;
9
    }
10
11
    filteredPosts = filteredPosts.filter((post) => {
12
      let currentValue = post[key]
13
      return Array.isArray(currentValue)
14
        ? currentValue.some((val) => currentKey.includes(val))
15
        : currentKey.includes(currentValue);
16
    });
17
  });
18
19
  postCount.innerText = filteredPosts.length;
20
21
  if (filteredPosts.length == 0) {
22
    noResults.innerText = "Sorry, we couldn't find any results.";
23
  } else {
24
    noResults.innerText = "";
25
  }
26
27
  postsContainer.innerHTML = "";
28
  filteredPosts.map((post) => createPost(post));
29
};

Conclusion

And with this, we’ve set up our multi-filter implementation!

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Web Design tutorials. Never miss out on learning about the next big thing.
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.