How to Build React Components That Are Actually Reusable
Because copying the same component into six folders is not a design system
There is something worse than bad code: a codebase filled with duplicated spaghetti code that nobody understands, nobody wants to touch, and everybody insists is “temporary.”
You know the kind.
components/
├── UserModal.tsx
├── AdminUserModal.tsx
├── NewUserModal.tsx
├── UpdatedUserModal.tsx
├── UserModalFinal.tsx
└── UserModalFinalV2_REAL.tsxAt that point, you do not have reusable components. You have a digital landfill with TypeScript support.
In this article, we will build truly reusable React components by focusing on a few practical principles:
- Single responsibility
- Prop-driven extensibility
- Composition over configuration
- Flat, intentional APIs
- Flexible styling without chaos
- Responsive behavior by default
These are guidelines, not commandments carved into a stone tablet by the React core team. Use judgment. The goal is not to create the most abstract component possible. The goal is to create components that are useful, predictable, and difficult to misuse.
1. Follow the Single Responsibility Principle
You have probably heard that a reusable component is simply a component that can be shared.
That definition is technically correct in the same way that reheated gas-station sushi is technically food.
A component becomes truly reusable when it has one clear responsibility and performs that responsibility exceptionally well.
Look at mature libraries such as MUI, Bootstrap, and Lodash. Their most useful building blocks tend to be small and focused:
- A button triggers an action.
- A modal displays content above the current interface.
- A chip communicates a compact status.
- A utility function transforms one type of value.
Each piece behaves like a LEGO brick. It has a clear purpose, a predictable interface, and enough versatility to work in many different structures.
It does not try to become the entire LEGO castle.
The wrong approach
This component displays a user, manages editing, performs an API request, displays notifications, and probably files the user’s taxes.
type UserCardProps = {
userId: string;
};
export function UserCard({ userId }: UserCardProps) {
const [user, setUser] = useState<User | null>(null);
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then((response) => response.json())
.then(setUser);
}, [userId]);
const handleSave = async () => {
await fetch(`/api/users/${userId}`, {
method: "PUT",
body: JSON.stringify(user),
});
alert("User saved!");
setIsEditing(false);
};
if (!user) {
return <div>Loading...</div>;
}
return (
<div>
<img src={user.avatarUrl} alt={user.name} />
{isEditing ? (
<input
value={user.name}
onChange={(event) =>
setUser({
...user,
name: event.target.value,
})
}
/>
) : (
<h2>{user.name}</h2>
)}
<button onClick={() => setIsEditing(true)}>Edit</button>
<button onClick={handleSave}>Save</button>
</div>
);
}This component is tightly coupled to:
- A specific API endpoint
- A specific data structure
- Editing behavior
- Loading behavior
- Notification behavior
- Persistence logic
Reusing it somewhere else means bringing its entire extended family with it.
A better approach
Keep the visual component focused on rendering.
export type UserCardProps = {
name: string;
avatarUrl?: string;
subtitle?: string;
actions?: React.ReactNode;
};
export function UserCard({
name,
avatarUrl,
subtitle,
actions,
}: UserCardProps) {
return (
<article>
{avatarUrl && <img src={avatarUrl} alt="" />}
<div>
<h2>{name}</h2>
{subtitle && <p>{subtitle}</p>}
</div>
{actions && <div>{actions}</div>}
</article>
);
}The parent decides where the data comes from and what actions are available.
<UserCard
name={user.name}
avatarUrl={user.avatarUrl}
subtitle={user.jobTitle}
actions={
<button onClick={() => openEditor(user.id)}>
Edit user
</button>
}
/>The component does one thing: it displays user information.
It does not know whether the user came from REST, GraphQL, Supabase, local storage, or an Excel spreadsheet maintained by someone named Greg.
That separation is what makes it reusable.
2. Use Prop-Driven Extensibility
First, let us clear up a common misunderstanding:
Prop drilling and prop spreading are not the same thing.
Prop drilling is passing data through multiple components that do not need it just to reach a deeply nested component.
Prop spreading is forwarding a group of props to another element:
<button {...buttonProps} />They are not enemies, friends, or even coworkers. They are completely different concepts.
A reusable component should expose the capabilities of the element it wraps whenever doing so is safe and useful.
The wrong approach
type PrimaryButtonProps = {
label: string;
onClick: () => void;
};
export function PrimaryButton({
label,
onClick,
}: PrimaryButtonProps) {
return <button onClick={onClick}>{label}</button>;
}This works until someone needs:
disabledaria-labeltype="submit"onFocusdata-testid- Keyboard events
- A tooltip
- A custom class name
The component author then adds one prop at a time until the type definition looks like a CVS receipt.
A better approach
Extend the native element’s props.
import type {
ButtonHTMLAttributes,
ReactNode,
} from "react";
export type PrimaryButtonProps =
ButtonHTMLAttributes<HTMLButtonElement> & {
children: ReactNode;
};
export function PrimaryButton({
children,
type = "button",
...buttonProps
}: PrimaryButtonProps) {
return (
<button type={type} {...buttonProps}>
{children}
</button>
);
}Now developers can use the button like a normal HTML button:
<PrimaryButton
type="submit"
disabled={isSaving}
aria-label="Save customer"
onClick={handleSave}
>
Save
</PrimaryButton>The component provides a useful default while preserving the native element’s capabilities.
Do not spread props blindly
The spread operator is useful, but it is not holy water. Do not sprinkle it everywhere and assume the architecture is now clean.
Be intentional about where the remaining props are applied.
export function SearchField({
label,
...inputProps
}: SearchFieldProps) {
return (
<label>
<span>{label}</span>
<input {...inputProps} />
</label>
);
}This makes sense because the additional props clearly belong to the input.
This is less clear:
export function MysteryComponent(props: Record<string, unknown>) {
return <div {...props} />;
}That is not flexibility. That is surrender.
Prop order matters
Consider this:
<button disabled={isLoading} {...buttonProps}>
Save
</button>A caller can override disabled because the spread comes last.
By contrast:
<button {...buttonProps} disabled={isLoading}>
Save
</button>The internal value wins.
Use the order intentionally:
- Put the spread last when callers should be allowed to override defaults.
- Put required internal behavior last when it must not be overridden.
3. Prefer Composition Over Configuration
One of the fastest ways to destroy a reusable component is to give it 47 Boolean props.
<DashboardCard
showTitle
showImage
showFooter
showActions
isCompact
isPremium
isAdmin
isExpandable
hasBorder
useNewDesign
useNewNewDesign
/>At this point, the component is not reusable. It is a hostage negotiation.
These “god components” try to support every possible design through configuration flags. The result is usually a giant collection of conditional statements that nobody can safely modify.
The LEGO horse principle
Imagine a LEGO horse.
The horse should not care whether you place a knight, queen, barrel, astronaut, or suspiciously confident monkey on top of it.
Its responsibility is to be a horse.
Likewise, a reusable component should often accept content through composition instead of trying to understand every possible type of content.
In React, that usually means using children and named slots.
The wrong approach
type PanelProps = {
title: string;
description: string;
buttonLabel?: string;
showIcon?: boolean;
iconName?: "warning" | "success" | "info";
showFooter?: boolean;
footerText?: string;
};This API assumes that every future panel will follow one exact structure.
The moment the design team adds a second button, an avatar, a dropdown, or a tiny animated llama, the component needs another prop.
A better approach
import type { ReactNode } from "react";
export type PanelProps = {
header?: ReactNode;
children: ReactNode;
footer?: ReactNode;
};
export function Panel({
header,
children,
footer,
}: PanelProps) {
return (
<section>
{header && <header>{header}</header>}
<div>{children}</div>
{footer && <footer>{footer}</footer>}
</section>
);
}Now the parent controls the content:
<Panel
header={
<div>
<h2>Server status</h2>
<StatusChip status="running" />
</div>
}
footer={
<button onClick={viewDetails}>
View details
</button>
}
>
<p>All systems are operating normally.</p>
</Panel>The structure remains consistent, but the content stays flexible.
That is composition.
4. Build a Reusable Modal
A modal is an excellent example because teams love creating one modal per feature until the bundle contains enough dialogs to launch a small operating system.
Here is a reusable modal built with MUI and TypeScript.
import type { ReactNode } from "react";
import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from "@mui/material";
import type { DialogProps } from "@mui/material/Dialog";
export type AppModalProps = Omit<
DialogProps,
"open" | "onClose" | "children" | "title"
> & {
open: boolean;
onClose: () => void;
title?: ReactNode;
children: ReactNode;
actions?: ReactNode;
};
export function AppModal({
open,
onClose,
title,
children,
actions,
maxWidth = "sm",
fullWidth = true,
...dialogProps
}: AppModalProps) {
return (
<Dialog
{...dialogProps}
open={open}
onClose={onClose}
maxWidth={maxWidth}
fullWidth={fullWidth}
>
{title && <DialogTitle>{title}</DialogTitle>}
<DialogContent>{children}</DialogContent>
{actions && (
<DialogActions>{actions}</DialogActions>
)}
</Dialog>
);
}The component handles the modal structure but does not decide what the modal is for.
Confirmation modal
<AppModal
open={isDeleteModalOpen}
onClose={closeDeleteModal}
title="Delete customer?"
actions={
<>
<Button onClick={closeDeleteModal}>
Cancel
</Button>
<Button
color="error"
onClick={deleteCustomer}
>
Delete
</Button>
</>
}
>
This action cannot be undone.
</AppModal>Form modal
<AppModal
open={isEditorOpen}
onClose={closeEditor}
title="Edit customer"
maxWidth="md"
actions={
<Button type="submit" form="customer-form">
Save
</Button>
}
>
<CustomerForm id="customer-form" />
</AppModal>Same modal. Different responsibility provided by the parent.
The reusable component owns:
- Dialog structure
- Sizing defaults
- Content areas
- Shared behavior
The parent owns:
- Business logic
- Form state
- API calls
- Button behavior
- Domain-specific content
Nobody needs to create DeleteCustomerModalFinal2.tsx.
Nature is healing.
5. Build a Reusable Status Chip
Suppose the product uses three common machine statuses:
runningoffstopped
We want these statuses to map to semantic visual tones:
running→ successoff→ neutralstopped→ danger
However, the displayed label should remain customizable.
For example, a dangerous status might display “Heating” instead of “Stopped.”
import { Chip } from "@mui/material";
import type { ChipProps } from "@mui/material/Chip";
export type Status =
| "running"
| "off"
| "stopped";
export type StatusTone =
| "success"
| "neutral"
| "danger";
const defaultLabels: Record<Status, string> = {
running: "Running",
off: "Off",
stopped: "Stopped",
};
const defaultTones: Record<Status, StatusTone> = {
running: "success",
off: "neutral",
stopped: "danger",
};
const muiColors: Record<
StatusTone,
NonNullable<ChipProps["color"]>
> = {
success: "success",
neutral: "default",
danger: "error",
};
export type StatusChipProps = Omit<
ChipProps,
"label" | "color"
> & {
status: Status;
label?: React.ReactNode;
tone?: StatusTone;
};
export function StatusChip({
status,
label,
tone,
size = "small",
...chipProps
}: StatusChipProps) {
const resolvedTone = tone ?? defaultTones[status];
return (
<Chip
{...chipProps}
label={label ?? defaultLabels[status]}
color={muiColors[resolvedTone]}
size={size}
/>
);
}Default usage
<StatusChip status="running" />Displays:
Runningwith a success appearance.
Custom label
<StatusChip
status="stopped"
label="Heating"
/>Displays:
Heatingwith a danger appearance.
Explicit tone override
<StatusChip
status="off"
label="Maintenance mode"
tone="danger"
/>This API provides useful defaults without trapping the developer inside them.
Get Samuel R’s stories in your inbox
Join Medium for free to get updates from this writer.
That is the balance reusable components should aim for.
6. Avoid Deeply Nested Object Props
Reusable components should not need a treasure map to find their data.
Avoid this
<CustomerHeader
customer={response.data.organization.customer}
/>Inside the component:
<h2>
{customer.account.profile.personalInformation.fullName}
</h2>The component is now coupled to the API response structure.
If the backend renames personalInformation to identity, your visual component breaks even though the UI requirements did not change.
Prefer this
<CustomerHeader
name={customer.fullName}
email={customer.email}
avatarUrl={customer.avatarUrl}
/>
type CustomerHeaderProps = {
name: string;
email?: string;
avatarUrl?: string;
};Only ask for the values the component actually uses.
Do not pass the entire customer object because the component needs the customer’s name.
That is like giving someone your entire wallet because they asked for a business card.
A useful exception
Passing an object is reasonable when the object represents a stable concept owned by the component.
type Coordinates = {
latitude: number;
longitude: number;
};
type MapMarkerProps = {
coordinates: Coordinates;
};The problem is not objects themselves. The problem is coupling reusable UI components to unstable, deeply nested application or API structures.
7. Start With the Design, Then Find the Reusable Patterns
When working from a wireframe or UI design, do not immediately start creating components based on every visible rectangle.
First, identify repeated visual and behavioral patterns:
- Cards with the same spacing and border treatment
- Repeated title and subtitle structures
- Status indicators
- Form-field arrangements
- Dialog structures
- Empty states
- Section headers
- Action groups
The component’s appearance should match the approved design. A reusable component is not an excuse to replace the designer’s work with whatever Bootstrap looked like in 2017.
At the same time, developers need enough flexibility to handle legitimate variations.
Designers occasionally break their own patterns to create emphasis. This is not necessarily a mistake. Visual hierarchy often requires intentional exceptions.
Your reusable component should support those exceptions without forcing developers to:
- Copy the component
- Add
!important - Target an internal class name
- Open a pull request titled “temporary styling fix”
- Carry that temporary fix for the next four years
For MUI components, exposing sx, className, and appropriate slot props is often enough.
type MetricCardProps = {
title: string;
value: React.ReactNode;
sx?: SxProps<Theme>;
};
<MetricCard
title="Active users"
value="12,480"
sx={{
borderWidth: 2,
transform: "scale(1.02)",
}}
/>Provide controlled escape hatches. Do not expose every internal implementation detail.
8. How Flexible Should a Reusable Component Be?
A reusable component should be flexible enough to support foreseeable variations, but not so flexible that it stops providing value.
Consider this component:
<Box {...props} />Technically, it can become anything.
A card. A sidebar. A header. A modal. A toaster. A spiritually confusing button.
But if your component adds no behavior, semantics, constraints, accessibility, or design-system value, it might not need to exist.
Too rigid
<WarningCard
title="Server offline"
description="The production server is unavailable."
/>It only supports one narrow structure.
Too flexible
<GenericContainer
element="section"
display="flex"
direction="column"
gap={2}
border
radius
shadow
responsive
maybeCard
/>You have rebuilt Box, but with worse documentation.
Useful flexibility
<AlertCard
severity="warning"
title="Server offline"
actions={<RetryButton />}
>
The production server is unavailable.
</AlertCard>This component provides:
- Semantic meaning
- Consistent styling
- Accessibility behavior
- A predictable structure
- Flexible content
That is a useful abstraction.
9. Layout Components Are Not Automatically Bad
It is tempting to say that you should never create components whose only purpose is styling or layout.
That rule is too strict.
Layout primitives such as Stack, Grid, Container, and Box can be extremely valuable when they encode consistent spacing, breakpoints, and design tokens.
What you should avoid are application-specific wrappers that add almost no value.
function WrapperWithTwentyFourPixelGap({
children,
}: PropsWithChildren) {
return (
<div style={{ display: "flex", gap: 24 }}>
{children}
</div>
);
}This probably does not need to be a component.
But a shared page container might:
export function PageContainer({
children,
}: PropsWithChildren) {
return (
<Container
maxWidth="xl"
sx={{
py: {
xs: 2,
md: 4,
},
}}
>
{children}
</Container>
);
}This component encodes a real system-level decision:
- Maximum page width
- Horizontal behavior
- Responsive vertical spacing
The question is not, “Does this component handle layout?”
The better question is:
Does this component encode a meaningful, repeated design decision?
10. Make Responsive Behavior the Default
Reusable components should behave responsibly before the developer writes a single media query.
A card should not explode beyond its container.
A modal should not require horizontal scrolling on a phone.
Text should wrap.
Content should shrink where appropriate.
Images should not escape into the next ZIP code.
A good reusable component should normally respect its parent container.
export function ContentCard({
children,
}: PropsWithChildren) {
return (
<Box
sx={{
width: "100%",
minWidth: 0,
boxSizing: "border-box",
}}
>
{children}
</Box>
);
}The parent can control the maximum width:
<Box sx={{ maxWidth: 720 }}>
<ContentCard>
...
</ContentCard>
</Box>This gives the component fluid behavior while allowing the surrounding layout to establish constraints.
Do not make everything full width
width: 100% is a sensible default for many structural components:
- Cards
- Form sections
- Tables
- Panels
- Page content
It is not automatically correct for:
- Buttons
- Chips
- Icons
- Avatars
- Menus
- Inline controls
Responsive defaults should match the component’s purpose.
A reusable button that always occupies the entire page is not responsive. It is needy.
11. Ask Only for Necessary Props
Every prop increases the component’s public API.
Every public API becomes something developers may depend on.
Every dependency becomes something you must support while someone in product says, “Can we just make this one small change?”
Keep the API small.
Avoid speculative props
type ProductCardProps = {
title: string;
description: string;
imageUrl: string;
showImage?: boolean;
imagePosition?: "left" | "right" | "top";
showBadge?: boolean;
badgeText?: string;
showFooter?: boolean;
footerText?: string;
compact?: boolean;
horizontal?: boolean;
premium?: boolean;
animated?: boolean;
};Do not add props because someone might possibly need them in the distant future.
That is how components develop a settings page.
Start with real requirements. Add capabilities when multiple legitimate use cases appear.
A component library is not a prediction market.
12. A Practical Reusability Checklist
Before adding a component to your shared library, ask:
Responsibility
- Does the component have one clear purpose?
- Does it mix UI rendering with business or API logic?
- Can its responsibility be explained in one sentence?
API design
- Does it request only the data it uses?
- Are its props flat and easy to understand?
- Does it expose native or library props where appropriate?
- Are defaults useful without being restrictive?
Composition
- Could
childrenor named slots replace several configuration flags? - Is the component becoming a collection of Boolean props?
- Can developers replace content without changing the component?
Styling
- Does it match the design system?
- Can developers use approved escape hatches such as
sx,className, or slot props? - Are internal implementation details protected?
Responsiveness
- Does it fit inside its parent by default?
- Does it work on small screens?
- Does content wrap or shrink correctly?
- Are width defaults appropriate for the component type?
Value
- Does the component encode meaningful behavior, semantics, accessibility, or design decisions?
- Is it more useful than directly using a library primitive?
- Has the pattern appeared more than once?
If the answer to the last question is no, you may not need a reusable component yet.
Sometimes duplication is cheaper than the wrong abstraction.
Yes, I said it.
Please remain calm.
Final Thoughts
The goal of a component library is not to eliminate every repeated line of JSX.
The goal is to create reliable building blocks that help developers move faster without sacrificing consistency, accessibility, or maintainability.
Truly reusable components tend to share a few qualities:
- They have one clear responsibility.
- They expose small, intentional APIs.
- They support extension through props.
- They prefer composition over endless configuration.
- They avoid coupling themselves to backend data structures.
- They provide responsive, useful defaults.
- They allow controlled customization.
- They solve actual repeated problems.
Build components like LEGO bricks: focused, predictable, and easy to combine.
Do not build one giant LEGO brick with 84 configuration flags and call it an architecture.
That is not a component library.
That is a monolith wearing a React hoodie.








