Building Reusable React Components That Last
The design principles that make a React component truly reusable: prop interfaces, the children pattern, controlled APIs, and warning signs of over-engineering.

Most developers think a reusable component is one that gets used in more than one place. If a Button shows up in six different features, it must be doing its job. But there’s a real difference between a component that’s been used in multiple places and one that can be used in any new context without modification.
The distinction matters. A component with eight boolean flags, a direct import from your auth store, and an assumption about its container’s layout isn’t reusable. It’s incidentally shared — and every caller inherits that baggage. The question isn’t how many times a component is used. It’s whether callers control it, or whether it quietly controls them.
I’m going to walk through the principles that actually make components reusable: single responsibility, prop interface design, composition patterns, controlled APIs, ref forwarding, and the warning signs that a component is doing too much.
Series: This is part of a series on React. The foundations live in React Fundamentals: Components, Props, and State, and the hooks that power interactive components are covered in React Hooks You’ll Actually Use Every Day.
On this page
- Single responsibility means the component doesn’t know where it lives
- Design your prop interface like a public API
- Children and slots solve problems before you need Context
- Controlled vs uncontrolled: let callers choose the mental model
- Forwarding refs when callers genuinely need DOM access
- Compound components: the pattern and when it pays off
- Warning signs that a component is trying to do too much
Single responsibility means the component doesn’t know where it lives
The React documentation’s guidance on component design describes this as separation of concerns: a component should ideally only be concerned with one thing. The practical test I find most useful is simpler — could you drop this component into a completely different project and have it work with just its props?
Consider a Button that tracks its own clicks to an analytics service:
// ❌ This Button knows too much about your app
function Button({ label, onClick }) {
async function handleClick() {
await analytics.track('button_clicked', { label });
onClick();
}
return <button onClick={handleClick}>{label}</button>;
}
That’s not a reusable Button. It’s a button tightly coupled to your analytics setup. If the analytics integration changes, the Button breaks. If someone needs this component in a different app, they carry the whole dependency with them.
A truly reusable Button accepts data and callbacks — it doesn’t call services:
// ✅ Button is just a button
function Button({ children, onClick, variant = 'primary', disabled = false }) {
return (
<button
className={`btn btn--${variant}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}
The analytics tracking belongs in the caller — the component that knows what just happened and why it matters. This is what I think of as the “island of complexity” principle: a component that works well accepts data and callbacks from outside, renders UI, and leaves decisions about what that data means to whoever renders it.
Design your prop interface like a public API
According to the React documentation, props are the only argument to a component — like function parameters, they define the entire public surface. A well-designed prop interface communicates what’s required, what’s optional, and how callers pass information back upward.
A few rules that have proven useful:
Required props have no defaults. If title is always needed, don’t give it a fallback. The missing-prop error at development time is a feature — it catches mistakes before they reach users.
Optional props have sensible defaults. A variant that defaults to 'primary' means callers only write what they’re changing from the common case. The interface gets out of the way.
Callbacks communicate upward, data flows downward. A component notifies its parent through callback props — onSubmit, onClose, onChange. The component handles the UI interaction; the caller decides what to do with the result.
function Card({ title, description, variant = 'default', onClose }) {
return (
<div className={`card card--${variant}`}>
<h3>{title}</h3>
<p>{description}</p>
{onClose && (
<button onClick={onClose} aria-label="Close">×</button>
)}
</div>
);
}
onClose is optional — when omitted, the close button doesn’t render. Callers that don’t need dismissal simply omit the prop. That’s the principle: required props define the component’s structure; optional props extend it without touching existing callers.
Children and slots solve problems before you need Context
Here’s the thing most developers discover later than they should: a significant portion of prop-drilling problems can be solved with the children prop, before you ever reach for Context.
The React documentation describes a component with a children prop as having “a hole that can be filled in by its parent components with arbitrary JSX.” The key insight is that when you use a children slot, the wrapping component stops being a middleman for data it doesn’t actually use.
Consider a Layout component that needs to show a header with user information:
// ❌ Layout has to know about users just to pass data along
function Layout({ userName, userAvatar, children }) {
return (
<div className="layout">
<Header userName={userName} userAvatar={userAvatar} />
<main>{children}</main>
</div>
);
}
// ✅ Layout knows nothing about users
function Layout({ header, children }) {
return (
<div className="layout">
{header}
<main>{children}</main>
</div>
);
}
With the slot approach, the caller composes what goes where:
<Layout header={<UserAvatar name={user.name} src={user.avatar} />}>
<ArticleList />
</Layout>
Layout doesn’t import UserAvatar. It doesn’t receive userName and pass it somewhere else. The prop-drilling disappears — not because state was lifted to a Context, but because the intermediate component stopped being a middleman for data it never needed.
This pattern also addresses the configuration-flag explosion. If you’re adding headerContent, footerContent, and sidebarContent props to a layout component, those are really slots. Passing JSX directly gives callers full control over what renders in each region without the component needing to enumerate every possible use case.
Controlled vs uncontrolled: let callers choose the mental model
React’s form inputs come in two flavours: controlled (the parent drives the value through props and callbacks) and uncontrolled (the element manages its own state). The same design choice applies to any stateful UI component — an accordion, a dropdown, a dialog.
The rule: if callers need to synchronise the component’s state with something external, make it controllable. If they just want the behaviour without managing the state, let the component own it.
The hybrid pattern supports both modes from a single component:
function Accordion({ title, isOpen, onToggle, defaultOpen = false, children }) {
const [internal, setInternal] = useState(defaultOpen);
const open = isOpen ?? internal;
function handleToggle() {
if (onToggle) {
onToggle(!open);
} else {
setInternal(prev => !prev);
}
}
return (
<div>
<button onClick={handleToggle}>{title}</button>
{open && <div>{children}</div>}
</div>
);
}
Pass isOpen and onToggle to control it externally. Omit them to let the component manage itself. The defaultOpen prop handles “start expanded” without handing over full control.
This matters most when a component appears in both simple contexts (a standalone FAQ on a page) and complex ones (an accordion in a sidebar that syncs with URL state). Supporting both avoids the need to maintain two separate components.
Forwarding refs when callers genuinely need DOM access
Sometimes a caller needs direct access to the DOM node inside a component — to focus an input, measure its size, or call a browser method that React doesn’t expose as a prop. This is what ref forwarding is for.
As of React 19, forwardRef is no longer required — refs can be passed as regular props alongside other props:
// React 19: ref is just another prop
function TextInput({ ref, ...props }) {
return <input ref={ref} {...props} />;
}
If you want to expose only a constrained subset of the DOM API — preventing callers from reaching into internals and, for example, directly modifying styles — use useImperativeHandle:
function TextInput({ ref, ...props }) {
const inputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
clear: () => { inputRef.current.value = ''; },
}));
return <input ref={inputRef} {...props} />;
}
Callers can focus() and clear(), but not reach into the underlying DOM node directly. The React documentation describes refs as an “escape hatch”: appropriate for low-level components like inputs, buttons, and media elements — not for application-level components where controlling behaviour through props is the better fit.
Compound components: the pattern and when it pays off
Compound components are a formal version of the slot pattern. Instead of a single children prop, you attach sub-components to a parent and let callers compose them explicitly. Tabs are the canonical example:
<Tabs defaultTab="overview">
<Tabs.List>
<Tabs.Tab id="overview">Overview</Tabs.Tab>
<Tabs.Tab id="details">Details</Tabs.Tab>
</Tabs.List>
<Tabs.Panel id="overview"><OverviewContent /></Tabs.Panel>
<Tabs.Panel id="details"><DetailsContent /></Tabs.Panel>
</Tabs>
The parent Tabs manages shared state (which tab is active) through Context, and the sub-components read from it automatically. Callers control the composition and the content; the component handles the coordination.
The pattern pays off when:
- Multiple sub-components share implicit state that would be tedious to wire through props manually
- You want callers to control the composition order and what renders in each slot
- The component has a clear “owner” concept —
Tabsowns which tab is active;Tabs.Tabonly knows its own ID
It adds real complexity: you’re managing a Context inside the component, and the sub-components become part of the public API you’ll need to maintain. Reach for it when a prop-based design would force callers to manually coordinate state that genuinely belongs to the component itself. For most situations, children is enough.
Warning signs that a component is trying to do too much
These signs compound gradually. By the time they’re obvious, the component is usually already painful to work with.
The prop list is past eight or ten items. That’s almost always responsibility creep — the component is accumulating features that belong in different places.
Multiple boolean flags each produce an entirely different layout. If isLoading, isEmpty, and isError each render a completely different structure, those are probably three separate components that happen to share some visual style.
The component imports from outside its own domain. An auth store import in a generic Button is a red flag. A router call inside a Modal is suspicious. Reusable components import only React and their direct dependencies.
When you copy it to a new project, the first thing you do is add a prop. That’s the tell. If it can’t work in a new context without modification, it was designed for your current context — not for general reuse.
Apply this to your codebase
- Pick a component that’s used in more than two places.
- List every prop. Separate the structural props from the ones encoding app-specific behaviour.
- For the app-specific ones, ask: could this be a callback? Could the caller own this decision instead?
Expected result: You’ll usually find at least one prop that belongs in the caller — and moving it simplifies both sides.
Design for the call site, not the implementation
The mental shift that makes component design easier: think about how the component will be used before thinking about how it will be built. Write the import and JSX first. If the call site looks awkward — too many props, hard to read, lots of conditionals before you even reach the component — the interface is wrong.
A component is a boundary. Data and callbacks flow in; rendered UI flows out. The components that last are the ones where that boundary is clean: callers don’t need to understand the internals, and the component doesn’t need to understand its callers.
For more on how hooks compose into reusable logic that lives separately from reusable UI, useEffect Explained covers the patterns that make hooks worth extracting into their own abstractions.