Create a Reusable Button Component in React

A React button component is a small function that renders a <button> and receives its label as children and its behavior as an onClick prop. Once it also accepts a variant, a size and a loading state, and forwards every other prop to the real button, you have one reusable button component for the whole app. This guide builds it step by step, explains type="button" vs submit inside forms, passing arguments to click handlers, and a typed version in TypeScript.

Every example was built with React 19.3.0, Vite 8.3.0 and TypeScript 7.0.2 and opened in Microsoft Edge; buttons were clicked with real mouse events and the outputs are the browser console messages or tsc output. Reference: Responding to Events on react.dev and <button> on MDN.

A simple React button component

import { useState } from "react";

function Button({ onClick, children }) {
  return <button onClick={onClick}>{children}</button>;
}

export default function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <Button onClick={() => setCount(count + 1)}>Clicked {count} times</Button>
    </div>
  );
}
React button component in Microsoft Edge showing Clicked 3 times after three real clicks
After three clicks.

A reusable Button with variants, sizes, icons and loading

The component below keeps the look in one place. Destructure the props it handles itself and pass the rest (type, onClick, id, aria-*) straight to the <button> with {...rest}, so it works like a normal button:

// Button.jsx
const styles = {
  base: { border: "none", borderRadius: 6, cursor: "pointer", fontWeight: 600, display: "inline-flex", alignItems: "center", gap: 6, margin: 4 },
  variant: {
    primary: { background: "#2563eb", color: "white" },
    secondary: { background: "#e5e7eb", color: "#111827" },
    danger: { background: "#dc2626", color: "white" },
  },
  size: { small: { padding: "4px 10px", fontSize: 13 }, medium: { padding: "8px 16px", fontSize: 15 }, large: { padding: "12px 22px", fontSize: 17 } },
};

export default function Button({ variant = "primary", size = "medium", loading = false, icon, disabled, children, ...rest }) {
  return (
    <button
      {...rest}                                         // type, onClick, aria-label, ...
      disabled={disabled || loading}
      style={{ ...styles.base, ...styles.variant[variant], ...styles.size[size], opacity: disabled || loading ? 0.6 : 1 }}
    >
      {icon}
      {loading ? "Saving..." : children}
    </button>
  );
}

// App.jsx
import { useState } from "react";
import Button from "./Button";

export default function App() {
  const [saving, setSaving] = useState(false);
  return (
    <div>
      <p>
        <Button>Primary</Button>
        <Button variant="secondary">Secondary</Button>
        <Button variant="danger" onClick={() => console.log("delete clicked")} id="delete">Delete</Button>
      </p>
      <p>
        <Button size="small">Small</Button>
        <Button size="medium">Medium</Button>
        <Button size="large">Large</Button>
      </p>
      <p>
        <Button icon={<span>&#11015;</span>} variant="secondary">Download</Button>
        <Button disabled>Disabled</Button>
        <Button loading={saving} onClick={() => setSaving(true)} id="save">Save</Button>
      </p>
    </div>
  );
}

Console output after clicking Delete and Save:

delete clicked
Reusable React Button component in Edge with primary, secondary and danger variants, small medium and large sizes, an icon button, a disabled button and a Save button in loading state
One component, many looks. The Save button switched to its loading state after the click.
  • Disable while loading so a double click cannot submit twice.
  • Keep a real <button>: it is focusable and works with the keyboard; a clickable <div> is not.
  • Icon-only buttons need an aria-label, which ...rest passes through.

Buttons inside forms: type=”button” vs submit

A <button> without a type inside a form is a submit button. Give buttons that should not submit type="button":

import { useState } from "react";

export default function App() {
  const [log, setLog] = useState([]);
  const add = (msg) => { console.log(msg); setLog((l) => [...l, msg]); };

  return (
    <form onSubmit={(e) => { e.preventDefault(); add("form submitted"); }}>
      <input defaultValue="ana@example.com" />
      <button id="default">Default button</button>                       {/* type="submit" inside a form */}
      <button type="button" id="plain" onClick={() => add("type=button: no submit")}>Preview</button>
      <button type="reset">Reset</button>
      <ul>{log.map((m, i) => <li key={i}>{m}</li>)}</ul>
    </form>
  );
}

Console output after clicking “Default button” and “Preview”:

form submitted
type=button: no submit
React form in Edge where the default button submitted the form and the type button only ran its onClick handler
The default button submitted the form; type="button" did not.

Pass arguments to an onClick handler

Wrap the call in an arrow function. Writing onClick={choose(plan)} would call the function during render instead of on click:

const plans = ["Basic", "Pro", "Team"];

export default function App() {
  function choose(plan, event) {
    console.log(`chose ${plan} (clicked <${event.currentTarget.tagName.toLowerCase()}>)`);
  }
  return (
    <div>
      {plans.map((plan) => (
        <button key={plan} id={plan} onClick={(e) => choose(plan, e)}>{plan}</button>   // arrow function passes the argument
      ))}
    </div>
  );
}

Console output after clicking Pro and Team:

chose Pro (clicked <button>)
chose Team (clicked <button>)

A typed button component in TypeScript

ComponentProps<"button"> gives the component every native button prop with the right types (including the event type of onClick), and a union type limits variant to the styles that exist:

import type { ComponentProps, ReactNode } from "react";

type ButtonProps = ComponentProps<"button"> & {
  variant?: "primary" | "secondary" | "danger";
  icon?: ReactNode;
};

export default function Button({ variant = "primary", icon, children, ...rest }: ButtonProps) {
  return (
    <button {...rest} className={`btn btn-${variant}`}>
      {icon}
      {children}
    </button>
  );
}

export const ok = <Button type="submit" onClick={(e) => console.log(e.currentTarget.form)}>Send</Button>;
export const wrong = <Button variant="link">Read more</Button>;

tsc output:

src/Button.tsx(18,30): error TS2322: Type '"link"' is not assignable to type '"danger" | "primary" | "secondary" | undefined'.
Command Prompt showing tsc error for a React TypeScript Button component: Type link is not assignable to type primary, secondary or danger
An unknown variant is caught at compile time; the valid button compiles.

More React component tutorials:

Frequently asked questions

How do I create a button component in React?

Write a function that returns a <button>: function Button({ onClick, children }) { return <button onClick={onClick}>{children}</button>; }.

How do I make a reusable button component?

Accept props such as variant, size and loading, and spread the remaining props onto the <button> with {...rest}.

Why does my button submit the form?

Buttons inside a form default to type="submit". Add type="button" to buttons that should only run onClick.

How do I pass a value to onClick?

Use an arrow function: onClick={() => choose(plan)}.

How do I disable a button in React?

Pass the disabled prop: <button disabled={isSaving}>.

How do I type a button component in TypeScript?

Use ComponentProps<"button"> for the native props and add your own, e.g. variant?: "primary" | "secondary".