humbledev

Creating a nice loading button with React Hooks

31.07.20193 Min Read — In React

Buttons are basic components that are found everywhere, from web apps to simple web static pages, but doing them properly is hard, from user-intuitive styling to accessibility, which is out of the scope of this post.

Here, we will focus on creating a button that provides a pleasant user experience when triggering an asynchronous action when clicking on the button.



This naive button implementation simply shows a loader while the user waits for the async action to finish, simulated by a setTimeout of 1 second:

import React from 'react';

function Button({ isLoading, children, ...props }) {
  return (
    <button className="button" {...props}>
      {isLoading ? <Loader /> : children}
    </button>
  );
}

function Example() {
  const [isButtonLoading, setIsButtonLoading] = React.useState(false);

  return (
    <Button
      onClick={() => {
        setIsButtonLoading(true);
        setTimeout(() => {
          setIsButtonLoading(false);
        }, 1000);
      }}
      isLoading={isButtonLoading}
    >
      Click me
    </Button>
  );
}

Problem is, the button changes its width and height to adapt to its content. Chances are the loader is much smaller than the usual text contents of a button, so triggering the loading state will also break the layout, making stuff "jump" on the screen, potentially attracting user’s attention to the jumping parts instead of the relevant parts and ruining the experience.



Let’s do something about the jump. What we can do is have the button have fixed dimensions, that we save once the button has displayed its content.

To do that, we need to save the button’s width and height using the React’s useState hook and use them in the render function. To get the width and height of the button, we’ll use the useRef hook to be able to get access to the button DOM. All of this happens inside a useEffect because getting dimensions from ref and saving them to the state is part of the imperative world and happens after the first render.

As pointed out on Reddit, this can also be done by simply using CSS visiblity along with opacity.
However, the end result would be different, because in our case, the loader does not exist in the DOM at all when the button is not in the loading state. By leaving it in the DOM, we would have some issues like the loader still rotating while being invisible and DOM clutter, which could potentially be an issue if you have a lot of buttons.


import React from 'react';

function NiceButton({ children, isLoading, ...props }) {
  /* Capture the dimensions of the button before the loading happens
  so it doesn’t change size when showing the loader */
  const [width, setWidth] = React.useState(0);
  const [height, setHeight] = React.useState(0);
  const ref = React.useRef(null);

  // Save the dimensions here
  React.useEffect(
    () => {
      if (ref.current && ref.current.getBoundingClientRect().width) {
        setWidth(ref.current.getBoundingClientRect().width);
      }
      if (ref.current && ref.current.getBoundingClientRect().height) {
        setHeight(ref.current.getBoundingClientRect().height);
      }
    },
    // children are a dep so dimensions are updated if initial contents change
    [children]
  );

  return (
    <button
      className="button"
      ref={ref}
      style={
        width && height
          ? {
              width: `${width}px`,
              height: `${height}px`,
            }
          : {}
      }
      {...props}
    >
      {isLoading ? <Loader /> : children}
    </button>
  );
}

We could have used a single useState hook to store both width and height in an object.
We could also have created a custom useDimensions containing both useState hooks and the single useEffect hook outside the button component for reusability.


Now our NiceButton doesn’t make the page contents "jump" when switching to and from its loading state:



Already better, but another issue is that the asynchronous operation could be much faster than the hard-coded 1 second timeout.
Let’s try with 50ms instead:



Showing something for less than, say, 400ms is way too fast for the user to comprehend stuff is happening, so I think it would make for a better experience if you could at least see the loader for a minimum duration, so the user has feedback that something happened after they clicked the button. A way we can do this is not actually using the isLoading prop directly to decide whether we show the loader or not, but instead manage a new showLoader state ourselves.

Let’s use a new useState hook to represent the new showLoader state:

const [showLoader, setShowLoader] = React.useState(false);

showLoader should be always equal to isLoading except when isLoading becomes false, then we want showLoader to stay true a bit longer. We can manage this side effect in another useEffect hook:

// Inside the NiceButton function
React.useEffect(() => {
  if (isLoading) {
    setShowLoader(true);
  }

  // Show loader a bits longer to avoid loading flash
  if (!isLoading && showLoader) {
    const timeout = setTimeout(() => {
      setShowLoader(false);
    }, 400);

    // Don’t forget to clear the timeout
    return () => {
      clearTimeout(timeout);
    };
  }
}, [isLoading, showLoader]);

// Use the new showLoader state
return (
  <button
    className="button"
    ref={ref}
    style={
      width && height
        ? {
            width: `${width}px`,
            height: `${height}px`,
          }
        : {}
    }
    {...props}
  >
    {showLoader ? <Loader /> : children}
  </button>
);

This effect should fire whenever one of isLoading or showLoader change so we add them as dependencies of our hook. Let’s see it in action:



We can now see the loader for a bit longer and know that something happened. It still can be improved though, because the transition to and from the loading state is a bit brutal. Adding a small fade in/fade out animation here can help the user understand what’s happening even more.

To do this, we’ll use the excellent react-spring library to handle the animation for us. It provides a convenient useSpring hook we pass some styling props to as well as an animated element we have to wrap our animation targets with. We pass the animation styles prop to the animated.divs. react-spring has some good defaults so we don’t have anything to provide except opacity.

This can also be done by using a simple css transition on opacity.


import { useSpring, animated } from 'react-spring';

function NiceButton({ children, isLoading, ...props }) {
  // Somewhere in our NiceButton component
  const fadeOutProps = useSpring({ opacity: showLoader ? 1 : 0 });
  const fadeInProps = useSpring({ opacity: showLoader ? 0 : 1 });

  return (
    <button
      className="button"
      ref={ref}
      style={
        width && height
          ? {
              width: `${width}px`,
              height: `${height}px`,
            }
          : {}
      }
      {...props}
    >
      {showLoader ? (
        <animated.div style={fadeOutProps}>
          <Loader />
        </animated.div>
      ) : (
        <animated.div style={fadeInProps}>{children}</animated.div>
      )}
    </button>
  );
}

Result:



There we have it, a much improved button!
Check the full code here: https://codesandbox.io/s/react-loading-button-7brkb

If you have any question or comment, ping me on twitter 😄