Designing the code behind product filters

You’ve seen these all over the internet, a sidebar containing controls that can filter a list, usually products in an online store. At my job as a front-end software engineer, we also made one of these, and I’d like to share with you how it works.

Examples of product filters

Our system needs to filter on a few different list types like customers, orders and more. So it needed to be generic in the type of user input it can handle and which filters it displays.

Here’s a basic example of a configuration object, with only one filter:

{
  filters: {
    release_date: {
      field: "release_date",
      label: "Release Date",
      inputType: "radio",
      options: {
        "last-30-days": { label: "Last 30 days" },
        "last-90-days": { label: "Last 90 days" },
        "last-7-days": { label: "Last 7 days" },
      },
      keys: [
        "last-7-days",
        "last-30-days",
        "last-90-days"
      ],
    },
  },
  keys: ["release_date"],
}

There are two main parts to the top level of this object, filters which contains a map of filter definitions, and keys which serves two purposes:

  1. It provides performant iterative access to the filters when listing them in the front-end UI.
  2. It allows the order of the filters to be specified, since object keys cannot be relied upon to be in the correct order when sent from our back-end API via JSON.

Having both filters as a map of ID to definition and keys as an array of filter IDs gives us the best of both worlds when accessing filters. Displaying them as a list in the UI or performing some transformation? Use the keys array. Wanting to access a single filter? Use direct access with filters[id].

A good example of that second one is when you are applying the user’s selected filters from the URL. For instance, /list?release_date=last-7-days you could easily know release_date is the selected filter and show that in the UI. This system lets us have human-readable query parameters pretty easily.

Below is an example of the markup you could write with this config. I’ve chosen AngularJS syntax because it’s easy to understand. Element attributes that start with ng- are logic statements and words wrapped in {{ }} are interpolated values.

<div
  ng-repeat="filterKey in config.keys"
  ng-init="filter = config.filters[filterKey]">

  <!-- Filter Heading -->
  <header>{{ filter.label }}</header>

  <!-- Filter Options -->
  <div ng-switch="filter.inputType">

    <div ng-switch-when="radio">
      <label ng-repeat="option in filter.keys">
        <input
          type="radio"
          name="{{ filter.field }}"
          ng-value="option"
          ng-model="filterModel[filter.field]">
        <span>{{ filter.options[option].label }}</span>
      </label>
    </div>

  </div>
</div>

To store what the user has selected we’re using this line:

ng-model="filterModel[filter.field]"

The filterModel is a simple map of filter.field to input value, which also maps great to URL query parameters. For example, our /list?release_date=last-7-days becomes { release_date: 'last-7-days' }.

So, what if we want something more complex? Well, you could add more input types for one. For example, you could create a custom component for entering a monetary value, made up of a number and a currency.

<div ng-switch-when="money">
  <money-filter
    model="filterModel[filter.field]"
    currencies="filter.options"
    filter-field-name="filter.field">
  </money-filter>
</div>

Now options becomes the available currencies. As long as the value can be serialised as a string, it can be put into our model. In this case, we could do <value>,<currency> e.g. 42.05,USD and the filter component knows how to split the model value into two parts.

Another desired behaviour is having a “custom” input value, such as in our “Release Date” filter above we could have a date picker as a fourth radio button option.

This can be achieved by providing a custom property to the filter definition:

{
  field: "release_date",
  custom: {
    inputType: "date",
  },
  ...

This new custom object could contain fields that the custom inputType requires, like min and max values or another label etc. The markup for this could be placed at the bottom of the list of options:

<div ng-switch="filter.inputType">
  <div ng-switch-when="radio">...</div>
  ...
</div>

<div ng-if="filter.custom">
  <div ng-switch="filter.custom.inputType">
    <!-- Custom inputs here -->
  </div>
</div>

And the same rule applies where the model must be a string. We’ll know if it’s custom instead of one of our pre-made options because the custom value will not appear there. In our code, filter.options[filterModel[filter.field]] will be undefined.

This structure has worked out well for us at work, and allowed us to add new properties and features fairly easily. For example, you could group filters by adding an array of categories and specifying which filter keys belong to each category, essentially splitting keys into smaller groups instead of one long array.

Thanks for reading!