How to Implement Multiple Filters in React
Jasser Mark Arioste
Hello, hustlers! In this tutorial, you'll learn how to implement multiple filters in React. We'll use NextJS with Typescript to implement this feature.
The Problem #
Suppose you're building an eCommerce app and you have multiple components that filter some products. But each component has different functionality, one is for search, one is for category, one is for branch, one is for price range, etc.
How do you represent the filter state so that even if you add, modify or remove filter components, the filter state representation stays the same?
The Solution #
Filtering usually has two steps, first is identifying the filter conditions and then using the filter conditions to an existing collection of elements.
To identify the filter conditions, we'll create a <FilterProvider/>
component that uses the React.Context API and a useFilters
custom hook to easily add or remove filter conditions.
Next, we'll use these filter conditions by calling a fetch request to the backend and rendering the filtered data on the frontend.
A filter condition usually has three parts: property
, operator
, and value
. I suggest representing a filter state using these three. For example, if you want to filter all products in the shoes category
and the price
is greater than $100 but less than $200.
To represent this as a state, I would use something like this:
export enum Operator { equals = "=", greater_than = ">", less_than = "<", } export type FilterValue = string | number | boolean | Date; export type FilterCondition = [string, Operator, FilterValue]; export const filterState: FilterCondition[] = [ ["category", Operator.equals, "shoes"], ["price", Operator.greater_than, 100], ["price", Operator.less_than, 200], ];
12345678910111213
Tutorial Objectives #
Here are our step-by-step objectives to create a multi-filter feature in React.
- Create the
<FilterProvder/>
Component using theReact.Context
API - Create the custom hook
useFilters()
- Create <CategoryFilter/> component
- Create a backend API endpoint
- Add filter implementation to the backend endpoint by using the filter input
- Integrate backend and frontend
Final Output #
Here's the final output of what we'll be making today:
Step 0: Project Setup #
First, let's create a new NextJS Project by running the following command:
npx create-next-app --ts --app react-multiple-filters
This creates a new NextJS project inside the /react-multiple-filters
folder. The --ts
option initializes the project to use Typescript and will create all files to use .ts
and .tsx
extension.
The --app
option will initialize it as an /app
router project as opposed to the old /pages
directory.
To start the local dev environment, simply run the following commands:
cd react-multiple-filters
yarn dev
# or
npm run dev
Step 1: Preparing the Data #
In this step, we'll use faker to generate a products.json
file to act as our database. First, install @faker-js/faker
package using the command:
yarn add @faker-js/faker
1
Next, create the file seed_db.js
in the root directory of your project.
const { faker } = require("@faker-js/faker"); const { writeFileSync } = require("fs"); async function seed() { // generate 100 products const products = Array.from(new Array(100), () => { const price = parseInt( faker.commerce.price({ dec: 0, max: 1000, min: 50 }) ); return { productName: faker.commerce.productName(), category: faker.commerce.department(), price: price, description: faker.commerce.productDescription(), material: faker.commerce.productMaterial(), }; }); writeFileSync("./products.json", JSON.stringify(products, null, 4)); } seed();
123456789101112131415161718192021
Next, run the script using the following command:
node seed_db.js
You should see a new products.json
file on the root directory of your project.
Step 2: Creating a Backend endpoint #
In this step, we'll create a backend endpoint that uses an in-memory database (@seald/nedb). The nedb package is an alternative to MongoDB which is good for tutorial purposes.
First, let's install the @seald/nedb
package by running the command:
yarn add @seald/nedb
Next, create the file /app/api/products/route.ts
:
// app/api/products/route.ts import DataStore from "@seald-io/nedb"; import products from "@/products.json"; import { NextResponse } from "next/server"; // create a new in-memory database const db = new DataStore(); // insert all products into the database const documents = products.map((prod) => { return db.insertAsync(prod); }); export async function GET(req: Request, {}) { await documents; const products = await db.find({}); return NextResponse.json(products); }
1234567891011121314151617
In the above code, we're importing the products.json
file and iterate through each product before inserting it into the database.
After this step, you can go to localhost:3000/api/products
, and you should get the following result:
Step 3: Creating the <FilterProvider/>
#
The <FilterProvider/>
will control the filter state of parts of the application. By using the React Context API, we can have access to the filter state inside any component, this makes it easy to add components that use or modify the filter state.
First, create the filter components/FilterProvider.tsx
.
// components/FilterProvider.tsx import { PropsWithChildren, createContext, useContext, useState } from "react"; // define filter operators export enum Operators { equal = "eq", not_equal = "neq", contains = "cont", not_contains = "ncont", greater_than = "gt", less_than = "lt", before = "before", after = "after", } // define the filter value type export type FilterValue = string | number | boolean | Date; // define the filter conditions type: field, operators, and values export type FilterCondition = [string, Operators, FilterValue]; // define the context state type ContextState = { filters: FilterCondition[]; setFilters(value: FilterCondition[]): void; }; // initialize filter context export const FilterContext = createContext<ContextState | null>(null); // define the FilterProvider export const FilterProvider = (props: PropsWithChildren) => { const [filters, setFilters] = useState<FilterCondition[]>([]); return ( <FilterContext.Provider value={{ filters, setFilters }}> {props.children} </FilterContext.Provider> ); };
1234567891011121314151617181920212223242526272829303132333435363738
In lines 3-17, we define or build the types for the filter state. As previously mentioned above, a filter condition has three parts: field
, operators
and value
. That's basically what we're defining here.
In lines 20-26, we define the FilterContext
.
In line 30, we use the useState
hook to initialize the filter state as an empty array.
Step 4: Creating the useFilters
hook.
#
In the same file, let's create the useFilters
custom hook below:
// components/FilterProvider.tsx import { PropsWithChildren, createContext, useContext, useState } from "react"; //... export const useFilters = () => { const context = useContext(FilterContext); if (!context) { throw new Error("Please use filter provider in the parent element"); } return context; };
12345678910111213
We'll use this hook later to get direct access to the FilterContext
.
Step 5: Creating the <CategoryFilter/>
Component
#
Next, let's create the <CategoryFilter/>
component. The category component which product categories will be shown in the UI.
First, Create the file components/CategoryFilter.tsx
and copy the code below:
// components/CategoryFilter.tsx "use client"; import products from "@/products.json"; import { Operators, useFilters } from "./FilterProvider"; // get all categories const categories = Array.from(new Set(products.map((p) => p.category))); const CategoryFilter = () => { const { filters, setFilters } = useFilters(); const handleChange = ( e: React.ChangeEvent<HTMLInputElement>, category: string ) => { if (e.target.checked) { // add filter condition if checked setFilters([...filters, ["category", Operators.equal, category]]); } else { // remove filter condition if uncheckd const newValue = filters.filter((condition) => condition[2] !== category); setFilters(newValue); } }; return ( <div> {categories.map((category, i) => { const checked = filters.some((condition) => { const [field, operator, value] = condition; return value === category && field === "category"; }); return ( <div key={i}> <input type="checkbox" id={`input-${category}`} onChange={(e) => handleChange(e, category)} checked={checked} /> <label htmlFor={`input-${category}`}> {category}</label> </div> ); })} </div> ); }; export default CategoryFilter;
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
Explanation:
First, we get all the existing categories from our products.json
file. Ideally, we should pull the data from an API but for tutorial purposes, we'll make do with this.
We iterate through each of them and use <input type="checkbox">
element.
In lines 13-25, we check if the element is checked and we modify the filter state as necessary.
In lines 29-32, we use the filters
state to see if a category checkbox is checked.
Next, let's modify the /app/page.tsx
file to use the <CategoryFilter/>
component
import styles from "./page.module.css"; import { FilterProvider } from "@/components/FilterProvider"; import CategoryFilter from "@/components/CategoryFilter"; export default function Home() { return ( <FilterProvider> <main className={styles.main}> <CategoryFilter /> </main> </FilterProvider> ); }
12345678910111213
After this step, you should have the following output or similar:
Step 6: Creating a <ProductList/> Component #
In this step, we'll create the <ProductList/>
component. The <ProductList/>
component will fetch data from our API and pass the filters from our filter state.
First, create the file, components/ProductList.tsx
. I added the explanations in the comments.
"use client"; import { useEffect, useState } from "react"; import { useFilters } from "./FilterProvider"; type Product = { productName: string; category: string; price: string; }; const ProductList = () => { const [data, setData] = useState<Product[]>([]); const { filters } = useFilters(); useEffect(() => { // 1.compose the url const url = new URL("http://localhost:3000/api/products"); // 2. add a filter parameter if there are multiple filter conditions const filterStr = filters .map((condition) => { const [field, operation, value] = condition; return `${field}-${operation}-${value}`; }) .join(","); // here's an example url with filters applied: // http://localhost:3000/api/products?filters=category-eq-Computers%2Ccategory-eq-Toys%2Ccategory-eq-Tools if (!!filters.length) { url.searchParams.append("filters", filterStr); } // 3. use fetch to get the data fetch(url) .then((response) => { if (response.ok) { return response.json(); } }) .then((products: Product[]) => { setData(products); }); }, [filters]); return ( <div> <pre>{JSON.stringify(data, null, 4)}</pre> </div> ); }; export default ProductList;
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
In lines 19-30, we're basically transforming our filter state into a query parameter filters
so that we can pass it to the backend as a GET request.
In lines 33-41, we make a simple fetch request using the URL with filters and set the state from the returned data.
In line 45, for the sake of this tutorial, we display the returned data in JSON form.
Next, let's modify the /page/app.tsx
to use the <ProductsList/>
component
import styles from "./page.module.css"; import { FilterProvider } from "@/components/FilterProvider"; import CategoryFilter from "@/components/CategoryFilter"; import ProductList from "@/components/ProductList"; export default function Home() { return ( <FilterProvider> <main className={styles.main}> <CategoryFilter /> <ProductList /> </main> </FilterProvider> ); }
123456789101112131415
After this step, your output should be something like this:
Step 7: Adding Filter Functionality to API #
Next, let's add filter functionality to our API. Since the database we use is NeDB or MongoDB, we'll transform the searchParameters
from the URL to a MongoDB query. If you're using a different database, you have to transform it to the query language of that database.
Let's modify the app/api/products/route.tsx
file as shown below:
import DataStore from "@seald-io/nedb"; import products from "@/products.json"; import { NextResponse } from "next/server"; // create a new in-memory database const db = new DataStore(); // insert all products into the database const documents = products.map((prod) => { return db.insertAsync(prod); }); export async function GET(req: Request) { await documents; const url = new URL(req.url); const filterStr = url.searchParams.get("filters"); console.log(filterStr); // transform the filter parameter into mongodb/nedb queries let queries = filterStr?.split(",").map((condition) => { const [field, operator, value] = condition.split("-"); if (operator === "eq") { return { [field]: value }; } else { return { [field]: { [`$${operator}`]: value } }; } }); const query = queries?.length ? { $or: queries } : {}; const products = await db.find(query); return NextResponse.json(products); }
123456789101112131415161718192021222324252627282930
In lines 18-25, we're basically transforming our filter string from this:
category-eq-Computers,category-eq-Toys,category-eq-Tools
1
To a MongoDB query like this:
{ "$or": [ { "category": "Computers" }, { "category": "Toys" } ] }
123456789
Transforming the filter string to a database query might require more logic if you have multiple properties like price
and description
.
After this step, you should be able to apply multiple filters when checking/unchecking the category checkboxes:
That's basically it!
Full Code and Demo #
The full code can be accessed at Github: jmarioste/react-multiple-filters-tutorial.
You can access the demo on Stackblitz: React Multiple Filters Tutorial
Conclusion #
You learned how to implement multiple filters in React and use that data to query the database in the backend server. React Context API is a good way to manage the filter state between different components. The other important thing is how to represent the filter state, and how to transform it to be used by the backend server.
If you like this tutorial, please leave a like or share this article. For future tutorials like this, please subscribe to our newsletter or follow me on GitHub.
Credits: Image by Xuan Duong from Pixabay