Sat Jan 21 2023

Passing fresh props to a throttled event handler through Redux

A solution to a recent issue I had where I was unable to pass updated props to a throttle onScroll event handler

Recently I was updating some class components to functional components (hooks ftw!) and one of those components was a Navigation Bar (navbar for brevity from hereon) that would be visible at the top of the page as the user would land. Once the user would scroll this navbar would be unpinned from the top. If the user scrolls up the navbar is then pinned again. This is a pretty common scenario and one which I'm sure you would have observed in many apps.

Below is a very basic example that I mapped out to illustrate. Note that im mocking alot of things here to keep the code short

1import React, { useEffect, useMemo } from "react";
2import { useSelector, useDispatch } from "react-redux";
3import throttle from "lodash.throttle";
4import { getIsNavbarPinned } from "./selectors";
5const NavBar = () => {
6 const pinned = useSelector(getIsNavbarPinned);
7 const dispatch = useDispatch();
8 const onScrollHandler = () => {
9 const logicToHandleScrollDown =
10 "some logic that decides whether the user scrolled down";
11 const logicToHandleScrollUp =
12 " some logic that decides whether the user scrolled up";
13 if (logicToHandleScrollDown && pinned) {
14 dispatch(pinNavbarAction({ navBarPinned: false }));
15 } else if (logicToHandleScrollUp && !pinned) {
16 pinNavbarAction({ navBarPinned: true });
17 }
18 };
19 // we throttle the onScrollHandler for performance optimisation
20 const throttledOnScroll = useMemo(
21 () => throttle(onScrollHandler, 300),
22 [pinned]
23 );
24 useEffect(() => {
25 window.addEventListener("scroll", throttledOnScroll);
26 return () => {
27 window.removeEventListener("scroll", throttledOnScroll);
28 };
29 // eslint-disable-next-line react-hooks/exhaustive-deps
30 }, []);
31 return <>{pinned ? <div>This will be the nav bar</div> : null}</>;
33export default NavBar;

In the above several things happen

  1. As the page loads on the client, we use the useEffect hook to register an event listener that will be invoked every time the user scrolls- throttledOnScroll().

  2. Since scroll is a rapidly firing event and we don't want performance degradation we have debounced the onScrollHandler() callback and wrapped it with useMemo. We then added pinned to the dependency array of the useMemo to ensure we refresh the debounced closure whenever the value of pinned was updated. You can see why its a good idea to use useMemo when debouncing or throttling in this article.

  3. The onScrollHandler() callback is the one doing the heavy lifting here and its job is to use some logic to decide whether the user is scrolling up or down so that it can dispatch a redux action, which will update our state. This state update will be registered on this component using the pinned value fetched from the selector. If pinned is true, we show the navbar and if not, it's hidden. The important thing to note here is that for onScrollHandler() to dispatch the action with the correct data, it has to have access to the current value of pinned.

The above code should have worked. But it did not as the navbar was hidden when the user scrolled down, but never appeared when the user scrolled up.

After digging deeper, I discovered that the reason this was happening was as onScrollHandler() was being created and held only the initial value of pinned (true). So the else if condition would never run as the conditions were not being met.

After some thinking, tinkering and consultation with a colleague, here is the solution that I came up with

1import React, { useEffect, useMemo } from "react";
2import { useSelector, useDispatch } from "react-redux";
3import throttle from "lodash.throttle";
4import { getIsNavbarPinned } from "./selectors";
5import { pinNavbarAction } from "./actions";
6const index = () => {
7 const pinned = useSelector(getIsNavbarPinned);
8 const dispatch = useDispatch();
9 useEffect(() => {
10 const onScrollHandler = () => {
11 const logicToHandleScrollDown =
12 "some logic that decides whether the user scrolled down";
13 const logicToHandleScrollUp =
14 " some logic that decides whether the user scrolled up";
15 if (logicToHandleScrollDown && pinned) {
16 dispatch(pinNavbarAction({ navBarPinned: false }));
17 } else if (logicToHandleScrollUp && !pinned) {
18 pinNavbarAction({ navBarPinned: true });
19 }
20 };
21 const throttledOnScroll = throttle(() => onScrollHandler(), 500);
22 window.addEventListener("scroll", throttledOnScroll);
23 return () => {
24 window.removeEventListener("scroll", throttledOnScroll);
25 };
26 // eslint-disable-next-line react-hooks/exhaustive-deps
27 }, [pinned]);
28 return <>{pinned ? <div>This will be the nav bar</div> : null}</>;
30export default index;

The above code fixed the issue of the navbar not appearing when the user scrolled up as I am now able to access the current value of pinned inside onScrollHandler(). Below is what I changed in the code to make this come about.

  1. I moved the onScrollHandler() and throttledOnScroll() inside the useEffect and removed the useMemo from throttledOnScroll(). This removal of useMemo was as we cannot use it inside useEffect.

  2. I added pinned to the dependency array of the useEffect.

Doing the above meant that we registered the throttledOnScroll() debounced function inside useEffect on the initial page render and every other time that the value of pinned changed. The onScrollHandler() callback therefore would always have the current value of pinned and behave as expected.

PS - you can also move the code inside the onScrollHandler() callback directly inside the throttle as well. This way you will not see the onScrollHandler() function being recreated everytime the value of pinned changes.

Do you agree with my approach here and/or have comments on how I can improve on this? Let me know in the comments below!