Using Throttling and Debouncing with React hooks - DEV Community
Excerpt
Throttling and debouncing techniques has been in use for past many years in javascript. In this post I'd like to share my knowledge on how we can use throttle and debounce functions with help of react hooks.
Consider below example with two routes /
and /count
rendering respective components.
export default function App() {
return (
<BrowserRouter>
<div>
<nav>
<ul>
<li>
<Link to="/">Home</Link>
</li>
<li>
<Link to="/count">Count</Link>
</li>
</ul>
</nav>
<Switch>
<Route path="/count">
<Count />
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</div>
</BrowserRouter>
);
}
Throttling Example with useEffect
Suppose we need to subscribe a scroll event on Count
component on its mount and just increment the count on every scroll event.
Code without using throttle or debounce techniques will be like:
function Count() {
const [count, setCount] = useState(1);
useEffect(() => {
window.addEventListener('scroll', increaseCount);
return () => window.removeEventListener('scroll', increaseCount);
}, []);
const increaseCount = () => {
setCount(count => count + 1);
}
return <h2 style={{marginBottom: 1200}}>Count {count}</h2>;
}
Suppose in practical applications you need to use throttle and wait for every 100ms before we execute increaseCount
. I have used the lodash throttle function for this example.
function Count() {
const [count, setCount] = useState(1);
useEffect(() => {
window.addEventListener('scroll', _.throttle(increaseCount, 100));
return () => window.removeEventListener('scroll', _.throttle(increaseCount, 100));
}, []);
const increaseCount = () => {
setCount(count => count + 1);
}
return <h2 style={{marginBottom: 1200}}>Count {count}</h2>;
}
Wait, no need to hurry. It will work if you are at /count
route. The increaseCount
function will be throttled and will increase the count after 100ms of intervals.
But as you move to the /
route to render the Home
component and unmount the Count
component, and start scrolling on home page, you will notice a warning in console which warns about memory leak. This is probably because the scroll event was not cleaned properly.
The reason is _.throttle(increaseCount, 100)
is called again during unmount and returns another function which does not match that created during the mount stage.
What if we create a variable and store the throttled instance.
like this
const throttledCount = _.throttle(increaseCount, 100);
useEffect(() => {
window.addEventListener('scroll', throttledCount);
return () => window.removeEventListener('scroll', throttledCount);
}, []);
But it has problem too. The throttledCount
is created on every render, which is not at all required. This function should be initiated once which is possible inside the useEffect hook. As it will now be computed only once during mount.
useEffect(() => {
const throttledCount = _.throttle(increaseCount, 100);
window.addEventListener('scroll', throttledCount);
return () => window.removeEventListener('scroll', throttledCount);
}, []);
Debounce Example using useCallback or useRef
Above example is pretty simple. Let's look at another example where there is an input field and you need to increment the count only after user stops typing for certain time. And there is text which is updated on every keystroke which re renders the component on every input.
Code with debounce:
function Count() {
const [count, setCount] = useState(1);
const [text, setText] = useState("");
const increaseCount = () => {
setCount(count => count + 1);
}
const debouncedCount = _.debounce(increaseCount, 1000);
const handleChange = (e) => {
setText(e.target.value);
debouncedCount();
}
return <>
<h2>Count {count}</h2>
<h3>Text {text}</h3>
<input type="text" onChange={handleChange}></input>
</>;
}
This will not work. The count will increase for every keystroke. The reason behind is that on every render, a new debouncedCount
is created.
We have to store this debounced function such that it is initiated only once like that in useEffect in above example.
Here comes use of useCallback
.
useCallback
will return a memoized version of the callback that only changes if one of the dependencies has changed - React docs
Replace
const debouncedCount = _.debounce(increaseCount, 1000);
with
const debouncedCount = useCallback(_.debounce(increaseCount, 1000),[]);
and it will work. Because this time the function is evaluated only once at the initial phase.
Or we can also use useRef
by doing this
const debouncedCount = useRef(debounce(increaseCount, 1000)).current;
One should always keep in mind that every render call of react functional component will lead to expiration of local variables and re-initiation unless you memoize them using hooks.
// TODO Throttle event listeners
window.addEventListener('scroll', handleScroll, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
};
}, []);
if (suppressNav || !hasSubPages) {
return null;
}
See: src/components/SecondaryNav/index.tsx - L:99
We probably don't need event fired for every single scroll.
/* eslint-disable complexity */
/* eslint-disable max-lines */
import React, { useState, useRef, useEffect } from "react";
import { SecondaryNavType } from "./types";
import { NavItem } from "./components/NavItem";
import { DropDownMenu } from "./components/DropDownMenu";
import { useLocation } from "react-router-dom";
const SecondaryNav = ({ items, suppressNav }: SecondaryNavType) => {
const currentItems = items[0];
const [active, setActive] = useState(-1);
let { pathname } = useLocation();
pathname = pathname?.split(" ").join("-").toLowerCase();
const isItemActive = (index: number) => active === index;
// Need to check if the current page is the route of a top level page or any subpages or subpages' subpages so we can display the glorious teal bar
const selectedIndex: Array<number> = [];
let topLevelSelected: number;
if (currentItems) {
// check if the current page is one of the top level navItems in the blue bar
topLevelSelected = currentItems.subpages.findIndex(
(x) => x.route.toLowerCase() === pathname
);
// check if the current page is in any of the subpages
for (let i = 0; i < currentItems.subpages?.length; i++) {
const select = currentItems.subpages[i]?.subpages?.findIndex(
(x) => x.route.toLowerCase() === pathname
);
if (select !== -1) {
selectedIndex.push(i);
break;
} else {
for (
let ii = 0;
ii < currentItems.subpages[i]?.subpages[ii]?.subpages?.length;
ii++
) {
const select = currentItems.subpages[i]?.subpages[
ii
]?.subpages?.findIndex((x) => x.route.toLowerCase() === pathname);
if (select !== -1) {
selectedIndex.push(i);
break;
}
}
}
}
}
const isItemSelected = (index: number) => selectedIndex.indexOf(index) !== -1;
const hasSubPages = currentItems?.subpages.length > 0;
const getNumberOfCols = (index: number) => {
if (currentItems?.subpages[index].subpages[0]?.subpages?.length) {
return currentItems.subpages[index].subpages.length;
}
return 1;
};
const dropdownDirection = (index: number) => {
const navItemsLength = currentItems?.subpages.length;
const numberOfCols = getNumberOfCols(index);
const placeInNav = index + 1;
const displayToRight = navItemsLength - placeInNav >= numberOfCols;
const displayToLeft = placeInNav > numberOfCols;
const leftSide = placeInNav <= navItemsLength / 2;
if (displayToRight) {
return { side: "left-0", position: "relative", cols: numberOfCols };
} else if (displayToLeft) {
return { side: "right-0", position: "relative", cols: numberOfCols };
} else if (leftSide) {
return { side: "left-0", position: "", cols: numberOfCols };
} else {
return { side: "right-0", position: "", cols: numberOfCols };
}
};
const rootElement = useRef<HTMLDivElement | null>(null);
const activeButton = useRef<HTMLButtonElement>(null);
useEffect(() => {
const handleClick = (event: Event) => {
const cTarget = event.target as Element;
if (
(cTarget instanceof HTMLElement &&
!rootElement.current?.contains(cTarget)) ||
cTarget.tagName === "A" ||
cTarget.tagName === "SPAN"
) {
setActive(-1);
}
};
window.addEventListener("click", handleClick);
return () => {
window.removeEventListener("click", handleClick);
};
}, []);
useEffect(() => {
const handlePress = (event: KeyboardEvent) => {
const { key } = event;
if (key === "Escape") {
activeButton?.current?.focus();
setActive(-1);
}
};
window.addEventListener("keyup", handlePress);
return () => {
window.removeEventListener("keyup", handlePress);
};
}, []);
// Close menu when scrolled off screen
useEffect(() => {
const handleScroll = ({ currentTarget }: Event) => {
if (!rootElement.current) {
return;
}
const headerDimensions = rootElement.current.getBoundingClientRect();
const headerDistanceFromTop = headerDimensions.top;
const headerHeight = headerDimensions.height;
const scrollingDown = (currentTarget as Window).scrollY > 0;
const headerOutOfView = headerDistanceFromTop + headerHeight < 0;
// offScreen
const offScreen = headerOutOfView && scrollingDown;
if (offScreen) {
// Close menu
setActive(-1);
}
};
// TODO Throttle event listeners
window.addEventListener("scroll", handleScroll, { passive: true });
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
if (suppressNav || !hasSubPages) {
return null;
}
return (
<div
className="hidden xl:block xl:-mt-px px-16 md:px-24 bg-blue text-white"
ref={rootElement}
>
<nav className="relative container-4xl h-full" aria-label="Secondary">
<ul className="flex justify-between -mx-16 h-full">
{currentItems.subpages.map(({ subpages, ...rest }, index) => {
const isActive = isItemActive(index);
const isSelected =
isItemSelected(index) || topLevelSelected === index;
return (
<li className={dropdownDirection(index).position} key={index}>
<NavItem
{...rest}
currentRef={isActive ? activeButton : null}
isActive={isActive}
isSelected={isSelected}
onClick={() => setActive(isActive ? -1 : index)}
type={subpages.length ? "button" : "link"}
/>
<DropDownMenu
{...{
subpages,
...{
...rest,
isActive,
dropdownDirection: dropdownDirection(index),
},
}}
/>
</li>
);
})}
</ul>
</nav>
</div>
);
};
export default SecondaryNav;