Building a loading button in React with TypeScript, shadcn/ui and Tailwind CSS

René Kulik on 18.09.2024

Introduction

When building modern user interfaces, it’s common to implement buttons with loading states to indicate that an operation is in progress. A typical approach is to replace the button’s content with a spinner. However, this substitution can cause the button to shrink or change size, resulting in a jumpy or uneven user experience. To address this, it’s important to ensure that the button maintains its size during loading.

In this blog post, I’ll walk you through building a LoadingButton component in React with TypeScript, using shadcn/ui for the button and Tailwind CSS for styling. We’ll ensure that the button width remains consistent even when displaying a loading spinner.

Understanding the problem

Imagine a button that normally displays text like “Submit”, but when a loading action begins, the text is replaced with a loading spinner. If the spinner is smaller than the text, the button will shrink when the spinner is shown. This size change can be visually disruptive and create a jarring user experience.

To address this, we need to capture the button’s width before the loading starts and apply that width during the loading state. This approach prevents any visual shrinking and maintains a consistent user experience.

The LoadingButton component

Let’s start by looking at the complete implementation of the LoadingButton component:

import { LoaderCircleIcon } from 'lucide-react';
import { FC, useRef, useState, useLayoutEffect } from 'react';
import { Button, ButtonProps } from '@/components/ui/button';

interface LoadingButtonProps extends ButtonProps {
  isLoading?: boolean;
}

export const LoadingButton: FC<LoadingButtonProps> = (props) => {
  const { isLoading, disabled, style, children, ...restOfProps } = props;
  const [buttonWidth, setButtonWidth] = useState<number | undefined>(undefined);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const buttonWidthStyle =
    isLoading && buttonWidth ? { minWidth: buttonWidth } : undefined;

  useLayoutEffect(() => {
    if (buttonRef.current) {
      setButtonWidth(buttonRef.current.offsetWidth);
    }
  }, []);

  return (
    <Button
      {...restOfProps}
      ref={buttonRef}
      style={{ ...style, ...buttonWidthStyle }}
      disabled={disabled ?? isLoading}>
      {isLoading ? <LoaderCircleIcon className="animate-spin" /> : children}
    </Button>
  );
};

Component props

The LoadingButton accepts a few key props:

The LoadingButtonProps interface extends the button props from shadcn/ui so the component can be used just like any standard button, but with the added loading functionality.

Managing button width with useState and useRef

We initialize two hooks:

const [buttonWidth, setButtonWidth] = useState<number | undefined>(undefined);
const buttonRef = useRef<HTMLButtonElement>(null);

Using useLayoutEffect to capture button width

useLayoutEffect is similar to useEffect, but it fires synchronously after all DOM mutations. This is important because it allows us to capture the button’s size before any visual changes (like switching to the spinner) are rendered on the screen.

useLayoutEffect(() => {
  if (buttonRef.current) {
    setButtonWidth(buttonRef.current.offsetWidth);
  }
}, []);

The useLayoutEffect runs only once (due to the empty dependency array []), setting the buttonWidth state to the button’s offsetWidth — the full width of the button including padding and borders.

Applying the captured width

When the isLoading prop is true, the button content changes to a spinner. To prevent the button from shrinking, we conditionally apply a style to the button that sets its minWidth to the previously captured buttonWidth.

When the isLoading prop is true, the button content changes to a spinner. To prevent the button from shrinking, we apply a style that sets its minWidth to the previously captured buttonWidth.

const buttonWidthStyle = isLoading && buttonWidth ? { minWidth: buttonWidth } : undefined;

This ensures that the button retains its original width, no matter how small the spinner is. The buttonWidthStyle is merged with any custom style passed as a prop when rendering the button.

Rendering the button

Finally, we render the button from shadcn/ui with either the spinner or its normal content depending on the isLoading state:

return (
  <Button
    {...restOfProps}
    ref={buttonRef}
    style={{ ...style, ...buttonWidthStyle }}
    disabled={disabled ?? isLoading}>
    {isLoading ? <LoaderCircleIcon className="animate-spin" /> : children}
  </Button>
);

The LoaderCircleIcon from the lucide-react library displays a loading spinner with an animate-spin class from Tailwind CSS to add the spinning animation. The disabled prop is set to true when the button is loading to prevent user interaction, unless it is explicitly specified otherwise.

Why use useLayoutEffect?

You might wonder why we use useLayoutEffect instead of useEffect. The key difference is that useEffect runs after the browser has painted the screen, which could lead to a visible “flash” where the button size changes before the effect runs.

useLayoutEffect, on the other hand, runs synchronously before the browser paints, ensuring that the size is set before the user sees anything. This guarantees a smooth user experience without any visual jumps or glitches.

Conclusion

The LoadingButton component is an effective solution for handling loading states while maintaining a consistent button size. By leveraging React’s useRef, useState, and useLayoutEffect, and using shadcn/ui for the button component along with Tailwind CSS for styling, we can prevent UI jumps and ensure a seamless experience for users.

This pattern is particularly useful for managing button states (such as loading, success, or failure) without causing visual disruption.