The implementation of a SafeButton in React

I have recently come across a problem. In a React web app, there are some buttons. There is an onclick handler for them, and they should get disabled onClick. Here is a simplified version:

Button.tsx

import * as React from "react";

type ButtonType = "button" | "submit" | "reset";

export interface ButtonProps {
    buttonType?: ButtonType;
    className?: string;
    disabled?: boolean;
    onClick?: React.MouseEventHandler<HTMLButtonElement>;
    children?: React.ReactNode;
}

export const Button: React.FC<ButtonProps> = ({
  buttonType = "button",
  className,
  disabled = false,
  onClick,
  children }) => {
    return (
        <button
            type={buttonType}
            className={className}
            disabled={disabled}
            onClick={onClick}
        >
          {children}
        </button>
    );
}

Footer.tsx

// Footer.tsx
import * as React from "react";
import { Button } from "./Button";

export const Footer: React.FC = () => {
    const [isWorking, setIsWorking] = React.useState(false);

    const handleClick: React.MouseEventHandler<HTMLButtonElement> = async (e) => {
        e.preventDefault();
        
        console.log('before: ', isWorking);
        setIsWorking(true);
        console.log('after: ', isWorking);
        try {
            await new Promise((r) => setTimeout(r, 800));
        } finally {
            setIsWorking(false);
        }
    };

    return (
        <footer className="footer">
        <Button
            buttonType="button"
            className="footerButton"
            disabled={isWorking}
            onClick={handleClick}
        >
        {isWorking ? "Working..." : "Click me"}
        </Button>
        </footer>
);
};

The problem with this approach under high system load

This is looking to be a very normal React way of creating a button, that is using the state to control the disabled/loading status of it. For example, when submitting a form, it might take some time for the backend to handle the request, before the backend return the success response, we want to disable the button to prevent multiple requests being sent. We might want to show a spinner whilst the request is being processed.

However, if you try to open up the console, you might see that the isWorking state is both being printed as false, even when one of the console.log happens after the setState function is called. Why is that?

The components would need rerendering upon the change of states. To avoid having to rerender the screen multiple times, React make the setState method to actually be async. It batches all the setState that is called and apply them all at the same time to boost performance. That is why at the second console.log, it is still printing the state as false, as the setState method is not immediately executed.

This actually works fine for most scenarios. But this actually creates a short gap (10-100 ms) between the user clicking the button for the first time, and the button being disabled after the re-rendering, and the user could have accidentally (or intentionally) clicked on the button twice. Ideally this API request should be idempotent, but that might not always be the case. Are there any ways to improve the Design of this button to stop the potential multiple clicks before the button become disabled?

The implementation of a SafeButton

We can actually try to use the useRef() hook to create a boolean flag called isClicked. The benefits of doing so is that the useRef flag is synchronous, meaning that once the onClick event is triggered the first time, it would auto set this flag to true, before the await keyword that creates a microtask for the API call. Even if the user is clicking on it in quick succession, they would be seeing the useRef is already set to true, and thus early returning the onClick handler, which helps to resolve the potential multi-click issues that is associated with useState.

export const SafeButton: React.FC<SafeButtonProps> = ({
    buttonType = "button",
    className,
    disabled = false,
    onClick,
    children,
    stopPropagation,
    preventDefault
}) => {
    const [isWorking, setIsWorking] = React.useState(false);
    const isClickedRef = React.useRef(false);

    const handleClick: React.MouseEventHandler<HTMLButtonElement> = async (e) => {
        if (stopPropagation){
            e.stopPropagation()
        }
        
        if (preventDefault){
            e.preventDefault()
        }

        // Check ref first - synchronous check
        if (isClickedRef.current || disabled) {
            return;
        }
        
        // Set ref immediately to prevent double clicks
        isClickedRef.current = true;
        setIsWorking(true);

        try {
            await onClick?.(e);
        } finally {
            setIsWorking(false);
            isClickedRef.current = false;
        }
    };

    return (
        <button
            type={buttonType}
            className={className}
            disabled={disabled || isWorking}
            onClick={handleClick}
        >
            {isWorking ? "Working..." : children}
        </button>
    );
};

Notes

An additional thing to point out is that we would also like to check out is that we should provide the control to stopPropagation and preventDefault as in the normal button. This is because that if we only handle the preventDefault/stopPropagation from the external callback function, it would be too late.

Key Differences in SafeButton

  1. useRef for immediate flag: isClickedRef.current is set synchronously before any async operations
  2. Early return: Checks the ref flag first, preventing multiple executions
  3. Internal state management: The button manages its own isWorking state
  4. Proper cleanup: Resets both state and ref in the finally block
  5. Event control: Calls preventDefault() and stopPropagation() when already clicked

The useRef check happens before React batches state updates, closing the race condition window.