The debounce and throttle methods from Lodash and Underscore.js wrap a function and return a new function that delays or
rate-limits its execution. React components re-render whenever their state or props change, and custom hooks execute as part of those renders, so any
value created in their bodies is recreated on every render.
debounce and throttle keep their pending state inside the returned wrapper function: the debounce timer, and the
timestamp of the last throttled call. When the wrapper is created in the body of a React function component, custom hook, or render
method of a React class component, every render produces a brand new wrapper with a fresh internal state. The previous wrapper’s pending timer still
fires on its own, and the new wrapper starts the delay window from scratch. The rate limiting is effectively lost: the wrapped function can end up
running far more often than intended.
This is easy to miss because the code looks debounced and usually works during casual testing. The bug only becomes visible when the component re-renders between two events, which resets the timer and lets the next event through immediately.
In a function component or custom hook, memoize the debounced or throttled function with useMemo so the same wrapper survives
re-renders. List in the dependency array every prop or state value the wrapped callback reads: an empty array is only safe when the callback reads
none of them, otherwise the memoized wrapper keeps calling a stale closure. A useRef can also keep the wrapper stable when it receives
the wrapper as its initial value or when it is initialized once with a null guard. React evaluates the initial-value expression on every render, but
it retains the wrapper from the initial render. In either component style, move its creation outside the component when it does not depend on props or
state. A class component can also create the wrapper in its constructor or as an instance property.
This rule reports debounce and throttle calls from Lodash, Lodash ES, and Underscore.js when they appear directly in the
body of a React function component or custom hook, or in the render method of a React class component, including an arrow-function
render class property. Calls wrapped in useMemo or useCallback, calls used as direct useRef
initial values, calls initialized once through a useRef null guard, and calls made outside these locations are not reported.
import { debounce } from "lodash";
function Search() {
const onChange = debounce(fetchResults, 300); // Noncompliant: recreated on every render, which resets the debounce timer
return <input onChange={onChange} />;
}
import { debounce } from "lodash";
import { useMemo } from "react";
function Search() {
const onChange = useMemo(() => debounce(fetchResults, 300), []);
return <input onChange={onChange} />;
}
import _ from "underscore";
import { useEffect } from "react";
function ActivityTracker() {
const trackScroll = _.throttle(logScrollPosition, 100); // Noncompliant: recreated on every render, which resets the throttling state
useEffect(() => {
window.addEventListener("scroll", trackScroll);
return () => window.removeEventListener("scroll", trackScroll);
});
return <main>{/* ... */}</main>;
}
import _ from "underscore";
import { useEffect } from "react";
const trackScroll = _.throttle(logScrollPosition, 100);
function ActivityTracker() {
useEffect(() => {
window.addEventListener("scroll", trackScroll);
return () => window.removeEventListener("scroll", trackScroll);
});
return <main>{/* ... */}</main>;
}
import React from "react";
import { debounce } from "lodash";
class Search extends React.Component {
render() {
const onChange = debounce(fetchResults, 300); // Noncompliant: recreated on every render, which resets the debounce timer
return <input onChange={onChange} />;
}
}
import React from "react";
import { debounce } from "lodash";
class Search extends React.Component {
constructor(props) {
super(props);
this.onChange = debounce(fetchResults, 300);
}
render() {
return <input onChange={this.onChange} />;
}
}
import { debounce } from "lodash";
function useDebouncedSearch(onSearch) {
return debounce(onSearch, 300); // Noncompliant: recreated every time the hook runs during a render
}
import { debounce } from "lodash";
import { useMemo } from "react";
function useDebouncedSearch(onSearch) {
return useMemo(() => debounce(onSearch, 300), [onSearch]);
}
import { debounce } from "lodash";
function Search() {
const onChange = debounce(fetchResults, 300); // Noncompliant: recreated on every render, which resets the debounce timer
return <input onChange={onChange} />;
}
import { debounce } from "lodash";
import { useRef } from "react";
function Search() {
const onChange = useRef(debounce(fetchResults, 300)).current;
return <input onChange={onChange} />;
}
_.debounce_.throttle_.debounce_.throttleuseMemo