DEV Community

David Ford
David Ford

Posted on

Prevent Double-Click Dups in React

I have an app with a Save button. And that Save button was was working just fine. Then one day we started seeing duplicate orders in the database. It turns out we had a Double-Clicker using our app. 

In this post, I'll show you how to prevent duplicate submissions (or duplicate whatevers) caused by someone double-clicking a button that was meant for a single click.

As common as this problem is, the fix is not as simple as you might think, especially in a React app using functional components.

The Problem

Let's start with this component:

function App() {
    const onClick = () => console.log("onClick");
    return <button onClick={onClick}>Click Me</button>;
}
Enter fullscreen mode Exit fullscreen mode

If you double click on this button, it runs the onClick function twice, which is likely not what you want.

The Fix

The way I fixed this was to make sure the onClick function is not called more than once in a 300 ms interval. This is called debouncing.

I use an npm package for this task. It's called debounce. Step one is to install that package.

This library wraps your onClick function, creating a new function, that you can use in its place. Here it is:

const onClick2 = debounce(onClick, 300)
Enter fullscreen mode Exit fullscreen mode

If you call onClick2 once, it simply calls onClick once.

If you call onClick2 once and then again 400 millis later, then onClick is called twice.

But, if you call onClick2 once and then again 200 millis later, then onClick is only called once. The second call is dropped. Debounced.

Here it is in context:

function App() {
    const onClick = () => console.log("onClick");
    const onClick2 = debounce(onClick, 300) 
    return <button onClick={onClick2}>Click Me</button>;
}
Enter fullscreen mode Exit fullscreen mode

Now, if you click the button 3 times in rapid succession, onClick2 will be called 3 times. But onClick will only be called once. The other two clicks will be dropped.

The Bug

But, we are not quite done yet. Our fix worked great when all we were doing in onClick was logging to the console.

But let's instead have our onClick function increment a state variable:

function App() {
    const [count, setCount] = useState(0);               
    const onClick = () => setCount(prev => prev + 1);
    const onClick2 = debounce(onClick, 300);         
    return <div>
        Count: {count}
        <br/>
        <button onClick={onClick2}>Click Me</button>
    </div>
}
Enter fullscreen mode Exit fullscreen mode

Now, when we double click the button, it increments twice.

Our debounce is no longer debouncing

Take a minute and try to figure out why it worked for console.log but not for setCount (a state change). Answer in next section.

The Bug Fix

Technically speaking, onSave2 is a local variable. That is a variable defined inside of a function (remember, MyComponent is a function). And, this function is called whenever React decides to render (or re-render) your component.

Local variables are totally destroyed and recreated every time that function is called.

Thus, onSave2 is destroyed and recreated every time React re-renders your component.

Why would React re-render your component? One reason is that a state variable has changed. Since your UI is a function of state, React must re-render after a state change.

So now, with all that said, take a minute (again) and see if you can figure out why it worked for a console log but not for a state change (answer in next section, this time I promise).

Fix for State Changes

So now, every time we click the button, the state value changes and our onClick2 function gets wiped out. We need for our function to persist across re-renders.

React provides a hook called useCallback (onClick2 is a callback after all). The useCallback hook is meant for exactly the problem we have: to avoid recreating the exact same function over and over again with each re-render.

Normally, recreating your callback function over and over does not present much of a problem. Creating a function is not an expensive operation.

But in this situation it does create a problem. Why? Because the onClick2 is more than just a function that calls onClick. It needs to keep track of how many times onClick has been called and how much time has elapsed. In other words, onClick2 contains some state. And this state is getting wiped out with each render. Thus, the call-count for onClick is perpetually stuck at 1. Like groundhog day.

To fix the problem we use the useCallback hook, so that the same function instance is used with each call to render.

Thus, our final fix is to wrap onClick2 with useCallback. So now, our original onClick function is double wrapped: with debounce and useCallback:

const onClick2 = debounce(onClick, 300, true); 
const onClick3 = useCallback(onClick2, []);
Enter fullscreen mode Exit fullscreen mode

Here it is in context:

function App() {
    const [count, setCount] = useState(0);
    const onClick = () => setCount(prev => prev + 1);
    const onClick2 = debounce(onClick, 300);
    const onClick3 = useCallback(onClick2, []);
    return <div>Count: {count}
        <br/>
        <button onClick={onClick3}>Click Me</button>
    </div>
}
Enter fullscreen mode Exit fullscreen mode

Fixing the Lag

You may have noticed a slight lag between clicking the button (single click) and the count updating. This is because, by default, debounce calls onClick at the end of the 300 ms interval.

This works fine for some apps. But for solving our double-click issue, I prefer if debounce called our onClick at the start of the 300 ms interval. This will get rid of the lag. To make this happen, pass in true for debounce's 3rd argument (called immediate):

const onClick2 = debounce(onClick, 300, true); 
Enter fullscreen mode Exit fullscreen mode

Dependency Arrays

You may have noticed the empty array we passed as the 2nd argument to useCallback. This is called the dependency array. An empty array tells React to preserve your callback function across re-renders, for as long as the component is alive (aka mounted). This works in our case and for most cases. But not all.

If your callback references a variable from it's outer scope you may end up with your callback using an outdated version of that variable (a stale closure). In this case, you should list those outer variables in the dependency array. This tells React to recreate your callback function when that variable changes. The result being that your callback function never has stale values.

Stale closures and dependency arrays are an important topic, but a bit out of scope for this post. Here are some good links:

Closures
Stale closures
Dependency-arrays

Top comments (0)