How to improve filtering on a product listing page with vanilla JS and React
Tags: javascript, react, ux, ecommerce
Published: 2023-06-01 10:23:10

A few days ago I was asked by my teammate designer to “tweak” our sidebar where the filters were placed. It looked pretty simple, however I realized how hard it was when I started coding and figured out that only CSS was not enough. Of course, as developers we usually get angry when designers ask us to create stuff that from their perspective is a no-brainer, but I had no words to argue when I saw his idea in action. That was a pretty fancy sidebar and I got surprised because I haven’t found popular e-commerces taking this approach. 

Most of e-commerces have a grid of products and a sidebar with options so the users can refine their search. But what if you have a hundred products, will your sidebar go along with the grid as you scroll down? You might be wondering “alright, just define the position as sticky and that’s it”. You’re not wrong, but what if you have several filter options that don’t fit the screen height? If we place the sidebar at the top using `position: sticky` the users wouldn’t see all filters that overflow the viewport:

{% embed https://codesandbox.io/embed/fancy-sticky-sidebar-forked-yhj8td?fontsize=14&hidenavigation=1&theme=dark %}

Also adding a `scroll auto;` to it is not a good solution because this would result in two columns with independent scrolls. That’s exactly the problem we were facing.

## Solution
In order to fix that, the designer proposed our sidebar to be always present no matter how long we had scrolled and whenever we reached the start or the end of the sidebar, it should get sticky. The goal here is to make the sidebar scroll with the grid so the users can use the filters without having to go all the way up to find them. Cool, ain’t? Before getting into the code, let’s summarize the logic to avoid getting lost:

* If the sidebar is shorter than viewport, it should get sticky to the top
* If the sidebar is taller than viewport
    * It should scroll with the grid
    * And scrolling down
        * It should get sticky to the bottom when reaching the end
    * And scrolling up
        * It should get sticky to the top when reaching the start

To accomplish this, we’re gonna have to toggle between the sticky and relative CSS positions and then calculate the top distance dynamically. Looks like someone has faced this problem and made a [package](https://abouolia.github.io/sticky-sidebar/) for that. By the way, that's where I grabbed the post image from. I didn't even try to use this library because it's not being maintained and probably has more code than we need. Considering it's from six years ago and thus doesn't implement the sticky position, I decided to come up with my own implementation by doing it in the react way using refs and a custom hook, but you can adapt the code to your needs. I’ll assume you have a two-column grid being the first one the filters sidebar and the second one the list of products, or something similar to this. That’s our basic structure to make it work:

```js
<main className="products-list">
  <aside className="products-list__sidebar">
    <Filters />
  </aside>
  <section className="products-list__list">
    { // your products }
  </section>
</main>
```

```css
.products-list {
  display: grid;
  grid-template-columns: 1fr 4fr;
}
```

Let’s get started by creating a new custom hook to abstract all logic related to this. I’ll name it `useStickySidebar`. The hook will receive a reference to the target element, in our case, the sidebar. Since we want to toggle the sidebar position, we need a factor to decide when it should happen. We can do that by listening to the scroll event and then whenever the user scrolls through the page, we trigger a handler function:

```js
const useStickySidebar = ({ targetRef }) => {
  const sidebarRef = targetRef;

  const calculateAndApplyPosition = useCallback(() => {
    console.log({ sidebarRef });
  }, [sidebarRef]);

  useEffect(() => {
    window.addEventListener(
      "scroll",
      calculateAndApplyPosition
    );

    return () => {
      window.removeEventListener("scroll", calculateAndApplyPosition);
    };
  }, [calculateAndApplyPosition]);
};
```


Now create a ref and attach it to your `<Filter />` component and instantiate the hook:

```js
const sidebarRef = useRef(null);

useStickySidebar({ targetRef: sidebarRef });

<main className="products-list">
  <aside className="products-list__sidebar">
    <Filters ref={sidebarRef} />
  </aside>
  <section className="products-list__list">
    { // your products }
  </section>
</main>
```

**Caveat**
> Be careful when listening to the scroll event and avoid updating react states. You might fall into performance issues!

Once it's done, you should be able to see your console logging the node ref. Now we're gonna add three methods to our hook that will be responsible to make our sidebar to become sticky or relative:

```js
const attachSidebarToTheTop = useCallback(() => {
  const sidebar = sidebarRef.current;

  sidebar.style.position = "sticky";
  sidebar.style.top = "0px";
}, [sidebarRef]);
```

The first one is pretty simple. Basically, it covers one of our requirements which is the standard behavior when the sidebar is smaller than the viewport.

```js
const attachSidebarToTheBottom = useCallback(() => {
  const sidebar = sidebarRef.current;

  const { innerHeight } = window;

  sidebar.style.position = "sticky";
  const top = innerHeight - sidebar.offsetHeight;
  sidebar.style.top = `${top}px`;
}, [sidebarRef]);
```

The second one does pretty much the same, but the idea here is to make the sidebar to become fixed at the bottom. We do that by calculating the difference between the viewport and the sidebar height. Most likely you will get a negative result, which is what we use to pull the sidebar up so that we see the last filter item. This covers the part where we reach the end of the sidebar but haven't reached the end of the products grid. So the product grid keeps scrolling while the sidebar is fixed at the bottom.

```js
const makeSidebarScroll = useCallback(() => {
  const sidebar = sidebarRef.current;
  const stickySidebarOffsetTop = sidebar.offsetTop;
  const offset = stickySidebarOffsetTop - sidebar.parentElement?.offsetTop;

  sidebar.style.position = "relative";
  sidebar.style.top = `${offset}px`;
}, [sidebarRef]);
```

Now the most tricky one but its name is very self-explainable. Basically, we apply `position: relative` combined with a proper `top` to make the sidebar scroll. To perform this math to get the `top` value, we grab the parent element (aside) offset top and subtract it from the sidebar offset top. It's important you follow our basic HTML structure because you must have a grid item as a parent element around the `<Filters />` so it takes the same height as the grid does.

The next step is to start implementing the `calculateAndApplyPosition` function. Again, we'll need to do some math here and use the results as conditionals to apply the methods we defined above. We also need to create a new ref to store the scroll position:

```js
const lastScrollY = useRef(0);

const calculateAndApplyPosition = useCallback(() => {
  const sidebar = sidebarRef.current;
  const { pageYOffset, innerHeight } = window;

  const isSidebarTallerThanViewport = sidebar.offsetHeight >= innerHeight;

  const hasReachedSidebarEnd =
    sidebar.offsetTop + sidebar.offsetHeight <=
    Math.round(pageYOffset + innerHeight);

  const isScrollingDown = lastScrollY.current < pageYOffset;
  const isScrollingUp = !isScrollingDown;
  lastScrollY.current = pageYOffset;

  const hasReachedSidebarStartByScrollingUp = pageYOffset <= sidebar.offsetTop;
}, [sidebarRef]);
```

Alright, now we just need to trigger our functions based on the conditions:

```js
const calculateAndApplyPosition = useCallback(() => {
  ...

  if (!isSidebarTallerThanViewport) {
    attachSidebarToTheTop();

    return;
  }

  if (
    isSidebarTallerThanViewport &&
    hasReachedSidebarEnd &&
    isScrollingDown
  ) {
    attachSidebarToTheBottom();

    return;
  }

  if (
    isSidebarTallerThanViewport &&
    hasReachedSidebarStartByScrollingUp &&
    isScrollingUp
  ) {
    attachSidebarToTheTop();

    return;
  }

  if (isSidebarTallerThanViewport) {
    makeSidebarScroll();

    return;
  }
}, [sidebarRef]);
```

Check out the result 🤩

{% embed https://codesandbox.io/embed/fancy-sticky-sidebar-2t1v4d?fontsize=14&hidenavigation=1&theme=dark&view=preview %}

However, we have a bug when we reach the end of the page and the sidebar gets bigger somehow, like expanding one of its filters:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ptnrgcckpuqx68hkoabc.gif)

In order to fix that we will need another method specific for this purpose:

```js
const lastSidebarHeight = useRef(0);

const fixSidebarOverflow = () => {
  const sidebar = sidebarRef.current;

  if (!sidebar || !sidebar.parentElement) return;

  const parentOffset = sidebar.parentElement.getBoundingClientRect().bottom;
  const sidebarOffset = sidebar.getBoundingClientRect().bottom;
  
  const overflowDetected = sidebarOffset > parentOffset;

  if (overflowDetected) {
    const diff = sidebar.offsetHeight - lastSidebarHeight.current;
    makeSidebarScroll(diff);
  }

  lastSidebarHeight.current = sidebar.offsetHeight;
};
```

Again we have to use some math here. First, we get how far the bottom of the sidebar parent is from the top . Then we do the same thing for the sidebar and check if one value is bigger than the other. If so, there's an overflow happening, which means that we can trigger our fix function and update the `lastSidebarHeight` ref. Notice that we pass a `diff` argument to the function and it's necessary to recalculate the sidebar top distance properly. Let's refactor the `makeSidebarScroll` to accept this param:

```js
const makeSidebarScroll = useCallback(
  (diff = 0) => {
    const sidebar = sidebarRef.current;
    const stickySidebarOffsetTop = sidebar.offsetTop;  
    const offset = stickySidebarOffsetTop - diff -sidebar.parentElement?.offsetTop;

    sidebar.style.position = "relative";
    sidebar.style.top = `${offset}px`;
  },
  [sidebarRef]
);
```

Since it only happens when the sidebar size changes let's use the [use-resize-observer](https://github.com/ZeeCoder/use-resize-observer) to listen to this change and then trigger a handler. Go ahead and install it, then instantiate the hook passing the ref and a handler that calls the method we created previously:

```js
const onTargetResize = () => {
 fixSidebarOverflow();
};

useResizeObserver({
 ref: targetRef,
 onResize: onTargetResize,
 wait: 0,
});
```

{% embed https://codesandbox.io/embed/fancy-sticky-sidebar-bug-fixed-vvju6j?fontsize=14&hidenavigation=1&theme=dark %}

## Tests
I don't think it's possible to write tests for this hook with testing-library due to the jsdom limitations. I recommend you use cypress, but I won't be covering it in this article.

## Final thoughts
The UX of our services is very important and we should always look attentively trying to improve as much as possible. Especially in e-commerce where the final goal is to convert users into buyers, we could have lost a lot of them just because they didn't find the filters to locate their products faster. I created a [repository](https://github.com/belgamo/fancy-sticky-sidebar) with the final code on my GitHub, check it out. 

Thanks for reading ❤️