Introduction
When I started building my Smart Budget Tracker app, I noticed I was copy-pasting button code everywhere - submit buttons, link buttons, loading buttons. Each one looked slightly different. That's when I decided to build one reusable button component to rule them all.
In this post I'll walk you through how I built it using React and TypeScript.
What We are Building
A single component that handles:
- Regular click buttons
- From submit buttons
- Navigation link buttons
- Loading state with a spinner
- Disabled state
- Multiple sizes and variants
Step 1 - Defines the Props Interface
The first thing I do in Typescript is define exactly what the component accepts. This gives you autocomplete and catches mistakes at compile time.
interface Props {
label: string;
onClick?: () => void;
disabled?: boolean;
loading?: boolean;
href?: string;
variant?: 'default' | 'primary';
size?: 'sm' | 'md' | 'lg';
fullWidth?: boolean;
type?: 'submit' | 'button';
className?: string;
}
The ? means the prop is optional, only label is required everything else has a default.
Step 2 - Set Default Values
function Button({
label,
onClick,
disabled = false,
loading = false,
href,
variant = 'default',
size = 'md',
fullWidth = false,
type = 'button',
className = '',
}: Props) {
Default values mean callers don't need to pass every prop, <Button label = "Save"/> just works.
Step 3 - Build the CSS class dynamically
Instead of writing if/else for every style combination, I build the class string from an array
const isDisabled = disabled || loading;
const wrapperCSSClass = [
'btn-base',
`btn-${variant}`,
`btn-${size}`,
fullWidth ? 'btn-full': '',
isDisabled ? 'btn-disabled': '',
className,
].filter(Boolean).join(' ');
filter(Boolean) removes any empty string so you don't get extra spaces in the class name. Adding a new variant is just one line.
Step 4 - Handle the Loading Spinner
When Loading is true, I show a spinner SVG icon and change the label text
const labelContent = (
<>
{loading && (
<svg className="animate-spin h-4 w-4" ...>
...
</svg>
)}
<span>{loading ? 'Loading...': label}</span>
</>
);
I also added aria- busy = {loading} on the button element. This tells screen readers that the button is busy - a small but important accessibility detail.
Step 5 - Handle Link Vs Button
This was the interesting part, sometimes a button navigates to another pages like a "Registration" link that looks like a button. I handle both cases:
if (href) {
return isDisabled
? <span className={wrapperCSSClass}>{labelContent}</span>
: <Link to={href} className={wrapperCSSClass}>{labelContent}</Link>;
}
return (
<button
type={type}
onClick={onClick}
disabled={isDisabled}
className={wrapperCSSClass}
aria-busy={loading}
>
{labelContent}
</button>
);
When href is passed, it renders a React Router <Link>. When disabled, it renders a <span> because a disabled link is semantically incorrect in HTML.
How to Use it
// Primary submit button
<Button label="Log in" type="submit" variant="primary" fullWidth loading={loading} />
// Navigation link styled as button
<Button label="Register Free" href="/Register" />
// Disabled button
<Button label="Save" disabled />
What I Learned
- TypeScript interfaces makes component self-documenting - you always know what props are available
- filter(Boolean) is a clean trick for building dynamic class strings
- One component can handle both
<button>and<Link>with a simple conditional render - Accessibility (aria-busy) is easy to add and makes a real difference
What's Next
In my next post l'll cover how I built a reusable TextInput component with error state, icons, and password toggle - also from my Smart Budget Tracker project.
If this helped you, drop a like or comment. I'm just getting started with blogging and any feedback is welcome!
Top comments (0)