Back to Top

How to build Magento 2 category page using ReactJS

Updated 24 October 2024

Learn how to build a Magento 2 category page using ReactJs in a headless development environment.

1. Introduction

2. Setting Up Your Category Page with ReactJs

3. Creating Your Category Page with ReactJs

4. Conclusion

Searching for an experienced
Magento 2 Company ?
Find out More

By leveraging Magento 2 headless architecture, this guide will help you integrate the Magento 2 backend with React.

Unlocking a more flexible and dynamic user experience and harnessing the full potential of headless commerce.

Introduction

Creating a category page in React for your Magento 2 store can significantly impact user engagement and encourage visitors to explore various product listings.

For a comprehensive guide on setting up product pages, check out our blog, How to Create a Magento 2 Product Page in React.

This guide will walk you through building an efficient and user-friendly category page, complete with code examples and detailed explanations.

And, In this blog you are going to know about Magento 2 React Development.

You’ll gain valuable insights into React.js development, learning how to:

  • Fetch data from the Magento 2 GraphQL API
  • Manage application state effectively
  • Design a responsive layout that enhances the overall shopping experience

By the end of this guide, you’ll have the skills to create a dynamic and engaging category page that elevates your e-commerce platform.

Setting Up Your Category Page with ReactJs

1. Create Your ReactJs Project:

Open your terminal and run the following command to create a React.Js Project

npx create-react-app my-magento-store
cd my-magento-store

2. Navigate to root directory:

cd my-magento-store

3. Install Necessary Packages In Your ReactJs:

//As, I created this with Tailwind so:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Creating Your Category Page with ReactJs

1. Set Up GraphQL Queries:

As we are using Magento 2 GraphQl API. So lets, create a file for your GraphQL queries. This keeps your code organized.

mkdir src/graphql
touch src/graphql/queries.js

export const GET_PRODUCT_BY_CATEGORY = `
  query(
  $filter: ProductAttributeFilterInput
  $pageSize: Int = 12
  $currentPage: Int = 1
  $sort: ProductAttributeSortInput
) {
  products(
    filter: $filter
    pageSize: $pageSize
    currentPage: $currentPage
    sort: $sort
  ) {
    page_info {
      current_page
      page_size
      total_pages
    }

    total_count
    aggregations {
      attribute_code
      label
      count
      options {
        count
        label
        value
      }
    }
    sort_fields {
      default
      options {
        label
        value
      }
    }
    items {
          media_gallery {
        disabled
        label
        url
        position
      }
        
      review_count
      rating_summary
      url_key
      uid
      sku
      name

      thumbnail {
        id: url
        url
        label
      }
      price_range {
        maximum_price {
          final_price {
            currency
            value
          }
          regular_price {
            currency
            value
          }
        }
        minimum_price {
          final_price {
            currency
            value
          }
          regular_price {
            currency
            value
          }
        }
      }
    }
    suggestions {
      search
    }
  }
}

`;

2. Created a fetch handler in reactjs:

For reusability created a fetch handler function in file called FetchHandler.ts.

// src/api/graphql.js

export const fetchGraphQL = (query, variables) => {
  return fetch("https://m243.winterroot.com/graphql", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ query, variables }),
  })
    .then((response) => response.json())
    .then((result) => {
      if (result.errors) {
        throw new Error(result.errors.map((err) => err.message).join(", "));
      }
      return result.data;
    });
};
React Category Page Desktop View

Reactjs Category Page Mobile view 1
Reactjs Category Page Mobile view 2

3. Render Category Page In Your React Application

Create Your Category Page Component

mkdir src/components/category
touch src/components/category/index.jsx

Next, add the following code to the index.jsx component:

import { useEffect, useState } from "react";
import SidebarFilter from "./SidebarFilter";
import { GET_PRODUCT_BY_CATEGORY } from "../../graphql/queries";
import Product from "./Product";
import { ArrowDownIcon, ArrowUpIcon, GridView, ListView, XMarkIcon } from "./Icons";
import { fetchGraphQL } from "./FetchHandler";
import Pagination from "./Pagination";
import SelectedList from "./SelectedFilter";
import Accordion from "./Accordion";
export default function Category() {
  const [product, setProduct] = useState();
  const [data, setData] = useState({});
  const [productView, setProductView] = useState("grid");
  const [filter, setFilter] = useState({});
  const [loading, setLoading] = useState(true);
  const [selectedOption, setSelectedOption] = useState(data?.products?.sort_fields.default);
  const [sort, setSort] = useState({});
  const [order, setOrder] = useState("DESC");
  const [currentPage, setCurrentPage] = useState(1);
  const [pageSize, setPageSize] = useState(12);
  const [openSideBar, setSideBar] = useState(false);
  const [selectedFilters, setSelectedFilters] = useState(data?.products?.applied_filters || []);
  const fetchProduct = async () => {
    fetchGraphQL(GET_PRODUCT_BY_CATEGORY, { filter, sort, currentPage, page_size: parseInt(pageSize) })
      .then((res) => {
        setData(res);
        const fetchedProduct = res.products.items || null;
        setProduct(fetchedProduct);
        setLoading(false);
      })
      .catch((error) => {
        setLoading(false);
        console.error("Error fetchin category data:", error.message);
      });
  };
  const asscendDescend = () => {
    if (order === "DESC") {
      setOrder("ASC");
    } else {
      setOrder("DESC");
    }
  };

  const handleChange = (event) => {
    const value = event.target.value;
    setSelectedOption(value);
  };

  useEffect(() => {
    if (openSideBar) {
      document.body.classList.add("overflow-hidden");
    } else {
      document.body.classList.remove("overflow-hidden");
    }

    // Cleanup to remove class on unmount
    return () => {
      document.body.classList.remove("overflow-hidden");
    };
  }, [openSideBar]);
  useEffect(() => {
    fetchProduct();
  }, [filter, sort, order, currentPage, pageSize]);
  useEffect(() => {
    if (selectedOption) {
      setSort({ [selectedOption]: order });
    }
  }, [order, selectedOption]);

  if (loading) {
    return <div>Loading...</div>;
  }
  if (!product) {
    return <div>No product found.</div>;
  }

  const removeFilter = (attributeCode) => {
    setSelectedFilters((prev) => {
      const { [attributeCode]: _, ...rest } = prev; // Destructure to remove the specified attribute
      return rest; // Return the remaining filters
    });

    // Update the filter state to remove the attribute code
    setFilter((prev) => {
      const updatedFilter = { ...prev };
      delete updatedFilter[attributeCode]; // Remove the attribute if it's empty
      return updatedFilter;
    });
  };
  const resetFilter = () => {
    setFilter({});
    setSelectedFilters([]);
  };
  return (
    <>
      <div
        className={`fixed inset-y-0 lg:hidden right-0 z-10 bg-white w-[99vw] transform ${
          openSideBar ? "translate-x-0" : "translate-x-full"
        } transition-transform overflow-y-auto duration-300 pt-5 ease-in-out`}
      >
        <SidebarFilter
          selectedFilters={selectedFilters}
          resetFilter={resetFilter}
          removeFilter={removeFilter}
          aggregations={data?.products?.aggregations}
          setFilter={setFilter}
          setSelectedFilters={setSelectedFilters}
        />
        <button onClick={() => setSideBar(false)} className="absolute right-0 p-2 rounded top-7">
          <XMarkIcon className="w-5 h-5 text-slate-500" />
        </button>
      </div>
      <div className="pb-20">
        <div className="container grid w-full px-4 mx-auto">
          <div className="grid lg:gap-5 lg:grid-cols-4">
            <div className="hidden lg:flex lg:flex-col lg:col-span-1">
              <SidebarFilter
                selectedFilters={selectedFilters}
                resetFilter={resetFilter}
                removeFilter={removeFilter}
                aggregations={data?.products?.aggregations}
                setFilter={setFilter}
                setSelectedFilters={setSelectedFilters}
              />
            </div>

            <div className="flex flex-col lg:col-span-3">
              <div className="flex justify-between px-2 mt-6 md:px-4">
                <div className="items-center hidden gap-2 lg:flex">
                  <div className="flex w-[70px] border border-black">
                    <div
                      onClick={() => setProductView("grid")}
                      className=" rounded-l-sm border-r border-0 hover:bg-gray-300 cursor-pointer border-x-gray-400 w-10 p-2 px-2  shadow shadow-gray-300/80 bg-[#efefef] text-gray-600"
                    >
                      <GridView />
                    </div>
                    <div
                      className="rounded-r-sm w-10 border-0 p-2 px-2 shadow cursor-pointer hover:bg-gray-300 shadow-gray-300/80 bg-[#efefef] text-gray-600
"
                      onClick={() => setProductView("list")}
                    >
                      <ListView />
                    </div>
                  </div>
                  <p className="text-xs">{data?.products?.total_count} items</p>
                </div>
                <button
                  className="flex px-4 py-1 text-sm border border-black lg:hidden bg-neutral-100 max-w-fit"
                  onClick={() => setSideBar(true)}
                >
                  Shop By
                </button>
                <div className="flex items-center gap-2">
                  <select
                    value={selectedOption}
                    onChange={handleChange}
                    className="outline-none py-[6px] text-sm border-black bg-gray-50 placeholder:text-gray-600 placeholder:px-2 border text-gray-900 focus:outline-none focus:ring-primary-600 focus:border-primary-600 block w-full p-1 px-2 rounded-sm dark:focus:ring-blue-500 dark:focus:border-blue-500 focus:shadow-[0_0_3px_1px_#00699d]"
                  >
                    <option disabled>{data?.products?.sort_fields.default}</option>
                    {data?.products?.sort_fields.options.map((option) => (
                      <option key={option.value} value={option.value}>
                        {option.label}
                      </option>
                    ))}
                  </select>
                  <button onClick={asscendDescend}>
                    {order === "DESC" ? (
                      <ArrowDownIcon className="w-5 h-5 stroke-2 stroke-slate-500" />
                    ) : (
                      <ArrowUpIcon className="w-5 h-5 stroke-2 stroke-slate-500" />
                    )}
                  </button>
                </div>
              </div>
              <div className="flex flex-col w-full mt-5 lg:hidden">
                {Object.entries(filter)?.length > 0 && (
                  <div className="border-t">
                    <Accordion title={`Now Shopping by : (${Object.entries(filter)?.length})`}>
                      <div className="pt-3 -ml-3">
                        <SelectedList
                          removeFilter={removeFilter}
                          resetFilter={resetFilter}
                          selectedFilters={selectedFilters}
                        />
                      </div>
                    </Accordion>
                  </div>
                )}
                <p className="mx-2 my-4 text-xs">{data?.products?.total_count} items</p>
              </div>
              {/*  */}
              <div
                className={`gap-4 ${
                  productView !== "grid" ? "flex flex-col" : "grid-cols-2 md:grid-cols-3 grid lg:grid-cols-4"
                }`}
              >
                {product?.map((i, index) => (
                  <Product productView={productView} key={index} product={i} />
                ))}
              </div>
              <Pagination
                data={data}
                currentPage={currentPage}
                setCurrentPage={setCurrentPage}
                setPageSize={setPageSize}
                pageSize={pageSize}
              />
            </div>
          </div>
        </div>
      </div>
    </>
  );
}

4. Add Sidebar Filter Component In Your Category Page

Create the Sidebar Filter Component

touch src/components/category/SidebarFilter.jsx

Implement the Filter Component To promote reusability, create a separate component for the available filter list based on the attributes received from the category list API.

Add the following code to SidebarFilter.jsx:

import Accordion from "./Accordion";
import SelectedList from "./SelectedFilter";


export default function SidebarFilter({
  aggregations,
  setFilter,
  setSelectedFilters,
  selectedFilters,
  resetFilter,
  removeFilter,
}) {
  const handleFilterChange = (attributeCode, value, label) => {
    if (attributeCode) {
      setSelectedFilters((prev) => {
        const currentSelections = prev[attributeCode] || [];
        const isSelected = currentSelections.includes(value);

        // Update the selected filters for checkboxes
        const newSelections =
          attributeCode === "price"
            ? [value] // Only one selection for price
            : isSelected
            ? currentSelections.filter((v) => v !== value) // Remove if already selected
            : [...currentSelections, value, label]; // Add if not selected

        return {
          ...prev,
          [attributeCode]: newSelections,
        };
      });
      // Update the filter state
      setFilter((prev) => ({
        ...prev,
        [attributeCode]:
          attributeCode === "price" ? { from: value.split("_")?.[0], to: value?.split("_")?.[1] } : { eq: value }, // Use 'in' for selected price range
      }));
    }
  };

  return (
    <div>
      {Object.entries(selectedFilters)?.length > 0 && (
        <div className="hidden lg:flex">
          <SelectedList removeFilter={removeFilter} resetFilter={resetFilter} selectedFilters={selectedFilters} />
        </div>
      )}
      <p className="flex p-2 text-2xl font-light border-b lg:text-base lg:font-medium">Shoping Options</p>
      {aggregations?.map((aggregation, index) => (
        <Accordion key={index} title={aggregation.label}>
          <div className="flex flex-col gap-2 px-1 py-2">
            {aggregation.options.map((option) => (
              <div key={option.value}>
                <label className="text-sm cursor-pointer">
                  <input
                    className="hidden"
                    type="radio"
                    value={option.value}
                    checked={selectedFilters.price === option.value} // Ensure only one selection for price
                    onChange={() => handleFilterChange(aggregation.attribute_code, option.value, option?.label)}
                  />
                  <span dangerouslySetInnerHTML={{ __html: option?.label }}></span> ({option.count})
                </label>
              </div>
            ))}
          </div>
        </Accordion>
      ))}
    </div>
  );
}
Reactjs Category Page Mobile view  filtersidebar

5. Add Selected Filter List Component In Your Category Page

Create the Selected Filter Component

touch src/components/category/SelectedFilter.jsx

Implement the Selected Filter Component To promote reusability, create a separate component for displaying the selected filters.

This component will include functions for removing filters and rendering the filter list. Add the following code to SelectedFilter.jsx:

import { XMarkIcon } from "./Icons";

const SelectedList = ({ removeFilter, resetFilter, selectedFilters }) => {
  return (
    <div className="flex flex-col px-2">
      <p className="hidden pb-5 text-base font-medium lg:flex">Now Shopping by</p>
      <ul>
        {Object.entries(selectedFilters).map(([attributeCode, options]) => (
          <li key={attributeCode} className="flex items-center text-sm gap-0.5">
            <button onClick={() => options.forEach((option) => removeFilter(attributeCode, option))}>
              <XMarkIcon className="w-5 h-5 text-slate-500" />
            </button>
            <span className="font-semibold capitalize cursor-pointer">{attributeCode.replace("_", " ")}</span>:{" "}
            {options.length > 1 ? options[1] : options[0].replace("_", " - ")}{" "}
          </li>
        ))}
        <li className="mt-5 text-sm cursor-pointer mb-7 text-sky-600" onClick={resetFilter}>
          Clear All
        </li>
      </ul>
    </div>
  );
};
export default SelectedList;

6. Add Pagination In Your Category Page

Create the Pagination Component

touch src/components/category/Pagination.jsx

Implement the Pagination Component To incorporate pagination and page size functionality, add the following code to Pagination.jsx:

import React from "react";
import { ChevronDownIcon } from "./Icons";

export default function Pagination({ data, currentPage, setCurrentPage, setPageSize, pageSize }) {
  const productCounts = Array.from(
    { length: Math.floor(data?.products?.total_count / 12) + 1 },
    (_, index) => index * 12
  );
  const handleChangePageSize = (event) => {
    const value = event.target.value;
    setPageSize(value);
  };
  const handlePageChange = (page) => {
    setCurrentPage(page);
  };

  const getPageNumbers = () => {
    const pages = [];
    const maxVisiblePages = 5;

    // Determine the range of pages to display
    let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
    let endPage = Math.min(data?.products?.page_info?.total_pages, startPage + maxVisiblePages - 1);

    // Adjust startPage if there are not enough pages before
    if (endPage - startPage < maxVisiblePages - 1) {
      startPage = Math.max(1, endPage - maxVisiblePages + 1);
    }

    // Build the array of page numbers
    for (let i = startPage; i <= endPage; i++) {
      pages.push(i);
    }

    return pages;
  };
  return (
    data?.products?.page_info?.total_pages > 1 && (
      <div className="flex items-center justify-between w-full">
        <div className="flex justify-center space-x-2">
          {/* Previous button */}
          {currentPage > 1 && (
            <button
              onClick={() => handlePageChange(currentPage - 1)}
              className="px-2 py-1 text-black bg-gray-300 rounded"
            >
              <ChevronDownIcon className="w-5 h-5 rotate-90 text-slate-500" />
            </button>
          )}
          {getPageNumbers().map((page) => (
            <button
              key={page}
              onClick={() => handlePageChange(page)}
              className={`p-1 rounded text-center text-xs ${
                currentPage === page ? "bg-neutral-200 text-black" : "text-sky-800"
              }`}
            >
              {page}
            </button>
          ))}

          {/* Next button */}
          {currentPage < data?.products?.page_info?.total_pages && (
            <button
              onClick={() => handlePageChange(currentPage + 1)}
              className="px-2 py-1 text-black bg-gray-300 rounded"
            >
              <ChevronDownIcon className="w-5 h-5 -rotate-90 text-slate-500" />
            </button>
          )}
        </div>
        <select
          value={pageSize}
          onChange={handleChangePageSize}
          className="outline-none max-w-fit py-[6px] px-4 text-sm bg-gray-50 placeholder:text-gray-600 placeholder:px-2 border border-gray-300 text-gray-900 focus:outline-none focus:ring-primary-600 focus:border-primary-600 block w-full p-1 px-2 rounded-sm dark:focus:ring-blue-500 dark:focus:border-blue-500 focus:shadow-[0_0_3px_1px_#00699d]"
        >
          <option disabled>{data?.products?.sort_fields.default}</option>
          {productCounts.map((option) => (
            <option key={option} value={option}>
              {option}
            </option>
          ))}
        </select>
      </div>
    )
  );
}
Category Page pagination

7. Create Product Card Component

Create A Product Card Component Component For Category Items

touch src/components/category/Product.jsx

Implement the Product Card Component To promote reusability, create a separate component for the product card structure that will be rendered in the category listing.

Add the following code to Product.jsx:

import { ChartBarIcon, HeartIcon, StarIcon } from "./Icons";

const Product = ({ product, productView }) => {
  const priceData = product?.price_range?.maximum_price?.final_price;
  const currency = "USD";
  const value = priceData?.value;
  const price = value?.toLocaleString("en-US", {
    style: "currency",
    currency,
  });
  const listType = productView !== "grid";
  return (
    <div className="group">
      <div className={`relative flex ${listType ? "flex-row" : "flex-col"} w-full overflow-hidden bg-white rounded-lg`}>
        <div className="relative grid object-cover mx-3 mt-3 overflow-hidden aspect-square h-60 rounded-xl">
          <img className="object-cover aspect-square" src={product.media_gallery?.at(0).url} alt={product.name} />
        </div>
        <div className="px-5 pb-5 mt-4">
          <h5 className="text-xl tracking-tight text-slate-900">
            {" "}
            <span dangerouslySetInnerHTML={{ __html: product?.name }}></span>
          </h5>
          <div className="flex items-center gap-2">
            {product?.review_count > 0 && (
              <div className="flex items-center">
                {Array.from({ length: 5 }, (_, index) => {
                  const ratingValue = index + 1;
                  const isFilled = ratingValue <= product.rating_summary / 20; // Assuming rating_summary is out of 100

                  return <StarIcon index={index} isFilled={isFilled} />;
                })}
                <span className="rounded text-sky-600 px-2.5 py-0.5 text-xs font-normal">
                  {product?.review_count} Reviews
                </span>
              </div>
            )}
          </div>
          <div className="flex items-center justify-between mt-2 mb-5">
            <p>
              <span className="font-bold text-md text-slate-900">{price}</span>
            </p>
          </div>
          <div className={`items-center gap-2  ${listType ? "flex" : "hidden group-hover:flex"}`}>
            <button className="w-full px-4 py-2 my-6 font-medium text-white max-w-fit bg-sky-600 hover:bg-sky-700">
              Add to cart
            </button>
            <HeartIcon className="w-5 h-5 text-slate-500" />
            <ChartBarIcon className="w-5 h-5 text-slate-500" />
          </div>
          {listType && <p className="text-sm text-sky-600">Learn More</p>}
        </div>
      </div>
    </div>
  );
};
export default Product;

8. Add Accordion Component For Your Category Page

Create Accordion Component

touch src/components/category/Accordion.jsx

Implement the Accordion Component To promote reusability, create a separate component for the accordion, which will be used to render various types of filter lists.

Add the following code to Accordion.jsx:

import { useState } from "react";
import { ChevronDownIcon, ChevronUpIcon } from "./Icons";

const Accordion = ({ children, title }) => {
    const [open, setOpen] =useState(false);
    return (
      <>
        <div className="w-full p-2 border border-t-0 border-l-0 border-r-0">
          <div onClick={() => setOpen(!open)} className="flex items-center justify-between w-full cursor-pointer">
            <div className="text-base font-medium capitalize">{title}</div>
            <div className="text-base">
              {open ? (
                <span>
                  <ChevronUpIcon className="w-5 h-5 text-slate-500" />
                </span>
              ) : (
                <span>
                  <ChevronDownIcon className="w-5 h-5 text-slate-500" />
                </span>
              )}
            </div>
          </div>
          {open && children}
        </div>
      </>
    );
  };
  export default Accordion;

9. Update App.jsx Component Of Your React Project

To ensure your React application effectively utilizes the new components you’ve created for the category page,

it’s essential to update the App.jsx component. This update will involve integrating the header and the category page into a cohesive layout.

import "./App.css";
import Category from "./components/category/Index";
import Header "./components/Header";
function App() {
  return (
    <>
      <Header />
      <Category />
    </>
  );
}

export default App;

10. Run Your ReactJs Application:

Finally, run your React.Js app to see your category page in action:

npm start

Conclusion

In this blog, we explored how to create a robust shopping category page using React in conjunction with the Magento API.

We covered essential functionalities, including sorting options, pagination, and dynamic filters that respond to user interactions.

Key Takeaways:

  • State Management: Leveraging React’s state hooks to manage category data ensures a seamless user experience.
  • GraphQL Integration: Utilizing the Magento 2 GraphQL API for fetching and displaying category information allows for efficient data handling and flexibility in managing category operations.

By following these principles and utilizing the components we’ve discussed, developers can create an engaging and efficient product listing experience.

That integrates smoothly with Magento 2’s powerful e-commerce capabilities.

With this foundation, you can further enhance features, improve performance, and refine the user interface to meet your specific business needs.

. . .

Leave a Comment

Your email address will not be published. Required fields are marked*


Be the first to comment.

Back to Top

Message Sent!

If you have more details or questions, you can reply to the received confirmation email.

Back to Home