- Introduction
- Understanding the problem
- The LoadingButton component
- Why use useLayoutEffect?
- Conclusion
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:
isLoading
: A boolean to indicate whether the button is in a loading state.disabled
: Whether the button is disabled. IfisLoading
istrue
, the button will automatically be disabled to prevent multiple submissions.style
: Custom styles to be applied to the button. These styles are merged with thebuttonWidthStyle
to ensure the button retains its width.children
: The normal content of the button, such as text like “Submit”.
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:
buttonWidth
: A state variable (useState
) that stores the current width of the button. This width will later be used to prevent the button from shrinking when loading.buttonRef
: A reference to the button element (useRef
), which allows us to access the button’s DOM properties.
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.