Building Reusable React Components
One of React's greatest strengths is its component-based architecture. But the gap between writing components that work and writing components that are truly reusable is enormous. A reusable component is one that can be dropped into any part of your application — or even a completely different project — and behave correctly with minimal configuration. Getting there requires deliberate design choices, not just functional code.
In this guide, we'll explore the principles, patterns, and practical techniques that experienced React developers use to build components that stand the test of scale. Whether you're building a design system or simply trying to avoid copy-pasting code across your app, these strategies will fundamentally improve how you structure your UI.
Don't abstract too early. A good heuristic: if you've copied a component three times, it's time to generalize it. Premature abstraction leads to over-engineered components with too many props and unclear responsibilities. Build once, duplicate once, then abstract on the third use.
What Makes a Component Truly Reusable?
Reusability isn't just about avoiding duplicate code — it's about creating components with the right boundaries. A reusable component has a single, well-defined responsibility, accepts external configuration through props, and makes no assumptions about where it lives in the component tree.
The key characteristics of a well-designed reusable component:
- Single Responsibility: Does one thing and does it well
- Prop-driven behavior: All dynamic behavior is controlled via props
- Unstyled or style-agnostic core: Logic and presentation can be separated
- Composable: Works with children and other components
- Accessible by default: ARIA attributes and keyboard support baked in
- Predictable API: Consistent prop names and types that match HTML conventions
Anatomy of a well-structured React component
Designing a Flexible Props API
The props API is your component's public contract. A well-designed API makes the component easy to use correctly and hard to use incorrectly. Start by studying how native HTML elements work — they're the best examples of flexible, well-designed APIs.
Use Sensible Defaults
Default props reduce boilerplate for consumers. The most common use case should require the fewest props:
// Bad — every prop required
function Button({ label, onClick, variant, size, disabled, type }) {
return (
<button type={type} disabled={disabled} onClick={onClick}
className={`btn btn-${variant} btn-${size}`}>
{label}
</button>
);
}
// Good — sensible defaults, extensible
function Button({
children,
onClick,
variant = 'primary',
size = 'md',
disabled = false,
type = 'button',
className = '',
...rest
}) {
return (
<button
type={type}
disabled={disabled}
onClick={onClick}
className={`btn btn-${variant} btn-${size} ${className}`.trim()}
{...rest}
>
{children}
</button>
);
}
Notice the use of ...rest — this spread of remaining props onto the underlying element is a critical pattern. It means consumers can pass aria-label, data-testid, id, or any other native attribute without the component needing explicit support for each one.
Prefer children Over Label Props
Using children instead of a label or text prop makes the component dramatically more flexible. Consumers can pass plain text, icons, or complex JSX without any changes to the component itself.
// Rigid — label prop limits flexibility
<Button label="Save Changes" />
// Flexible — children works for everything
<Button>Save Changes</Button>
<Button><i className="fas fa-save" /> Save Changes</Button>
<Button><Spinner /> Saving...</Button>
Composition Over Configuration
The most powerful pattern for reusability in React is composition. Instead of building one mega-component with 30 props to cover every use case, you build several small components that work together. This is how React's own APIs are designed — think <select> and <option>.
The Compound Component Pattern
Compound components share state implicitly via React Context, letting the consumer assemble pieces in any order without prop drilling:
import React, { createContext, useContext, useState } from 'react';
const TabsContext = createContext(null);
function Tabs({ children, defaultTab = 0 }) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function TabList({ children }) {
return <div className="tab-list" role="tablist">{children}</div>;
}
function Tab({ children, index }) {
const { activeTab, setActiveTab } = useContext(TabsContext);
return (
<button
role="tab"
aria-selected={activeTab === index}
className={`tab ${activeTab === index ? 'active' : ''}`}
onClick={() => setActiveTab(index)}
>
{children}
</button>
);
}
function TabPanel({ children, index }) {
const { activeTab } = useContext(TabsContext);
if (activeTab !== index) return null;
return <div role="tabpanel">{children}</div>;
}
// Attach sub-components
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
export default Tabs;
Consumers can now build tabs with full control over layout and content:
import Tabs from './Tabs';
function App() {
return (
<Tabs defaultTab={0}>
<Tabs.List>
<Tabs.Tab index={0}>Overview</Tabs.Tab>
<Tabs.Tab index={1}>Details</Tabs.Tab>
<Tabs.Tab index={2}>Settings</Tabs.Tab>
</Tabs.List>
<Tabs.Panel index={0}><p>Overview content here</p></Tabs.Panel>
<Tabs.Panel index={1}><p>Details content here</p></Tabs.Panel>
<Tabs.Panel index={2}><p>Settings content here</p></Tabs.Panel>
</Tabs>
);
}
Compound components using Context are tightly coupled — a Tabs.Tab only works inside a Tabs parent. Always add a guard: if (!context) throw new Error('Tab must be used inside Tabs') to give consumers a clear error instead of a cryptic undefined crash.
The Render Props Pattern
Render props let you share stateful logic while giving consumers total control over what gets rendered. They've largely been superseded by hooks, but remain useful for components that render to a portal or need to pass render context down to JSX:
function Toggle({ children }) {
const [on, setOn] = React.useState(false);
const toggle = () => setOn(prev => !prev);
return children({ on, toggle });
}
// Usage — consumer decides what to render
<Toggle>
{({ on, toggle }) => (
<div>
<button onClick={toggle}>{on ? 'Hide' : 'Show'}</button>
{on && <div className="panel">Hidden content revealed!</div>}
</div>
)}
</Toggle>
Extracting Logic with Custom Hooks
Custom hooks are the most modern and idiomatic way to make logic reusable in React. They let you extract stateful logic from components entirely, so the same behavior can power completely different UIs.
Building a useToggle Hook
import { useState, useCallback } from 'react';
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(v => !v), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return { value, toggle, setTrue, setFalse };
}
export default useToggle;
A Practical useFetch Hook
Instead of duplicating loading/error/data logic across every component that fetches data, extract it into a hook:
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!url) return;
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
return res.json();
})
.then(json => { if (!cancelled) setData(json); })
.catch(err => { if (!cancelled) setError(err.message); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
export default useFetch;
// Usage in any component
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return <div>{user.name}</div>;
}
The cancelled flag is critical — it prevents state updates on unmounted components, avoiding memory leaks and React warnings when the URL changes before the previous fetch completes.
Choosing the Right Pattern
Each pattern has a specific strength. Choosing the wrong one creates unnecessary complexity. Here's a reference table:
| Pattern | Best For | Drawback | Example Use Case |
|---|---|---|---|
| Props + children | Simple, presentational components | Not scalable for complex state sharing | Button, Badge, Card |
| Compound Components | Related components sharing implicit state | Sub-components coupled to parent | Tabs, Accordion, Select |
| Render Props | Sharing render control with logic | Callback hell with nesting | Mouse tracker, Portal wrappers |
| Custom Hooks | Sharing stateful logic without UI opinion | Can't directly share JSX | useFetch, useForm, useDebounce |
| HOC | Cross-cutting concerns (auth, logging) | Prop collision, hard to debug | withAuth, withLogger |
Building a Complete, Reusable Form Input
Let's put everything together in a practical example — a form input component that covers the real-world requirements of a design system: labels, error states, helper text, icons, and full accessibility.
Define the Props Interface
Map out every variant before writing code. For an input: label, value, onChange, error, helperText, leadingIcon, trailingIcon, disabled, required.
Write the Core Component
Implement the happy path first — label + input + error message. Add variants and extras only once the base works correctly.
Add Accessibility
Wire up aria-invalid, aria-describedby for helper/error text, and ensure the label's htmlFor matches the input's id.
Forward the Ref
Wrap in React.forwardRef so consumers can imperatively focus or measure the input when needed.
import React, { useId } from 'react';
const TextInput = React.forwardRef(function TextInput(
{
label,
error,
helperText,
leadingIcon,
trailingIcon,
className = '',
required = false,
disabled = false,
...rest
},
ref
) {
const id = useId();
const descId = useId();
const hasDesc = !!(error || helperText);
return (
<div className={`field ${disabled ? 'field--disabled' : ''}`}>
{label && (
<label htmlFor={id} className="field__label">
{label}
{required && <span aria-hidden="true"> *</span>}
</label>
)}
<div className={`field__input-wrapper ${error ? 'field__input-wrapper--error' : ''}`}>
{leadingIcon && (
<span className="field__icon field__icon--leading" aria-hidden="true">
{leadingIcon}
</span>
)}
<input
ref={ref}
id={id}
disabled={disabled}
required={required}
aria-invalid={!!error}
aria-describedby={hasDesc ? descId : undefined}
className={`field__input ${leadingIcon ? 'field__input--leading' : ''} ${trailingIcon ? 'field__input--trailing' : ''} ${className}`}
{...rest}
/>
{trailingIcon && (
<span className="field__icon field__icon--trailing" aria-hidden="true">
{trailingIcon}
</span>
)}
</div>
{hasDesc && (
<p id={descId} className={`field__hint ${error ? 'field__hint--error' : ''}`}>
{error || helperText}
</p>
)}
</div>
);
});
export default TextInput;
Key design decisions in this component: useId() generates stable, unique IDs without manual management; aria-describedby only sets when there's actually text to describe; the spread ...rest ensures every native input attribute works; and forwardRef maintains ref transparency.
Testing Reusable Components
A component isn't truly reusable until it's tested. Good tests verify behavior from the consumer's perspective, not implementation details.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TextInput from './TextInput';
describe('TextInput', () => {
it('renders label and associates it with input', () => {
render(<TextInput label="Email" />);
expect(screen.getByLabelText('Email')).toBeInTheDocument();
});
it('shows error message and marks input invalid', () => {
render(<TextInput label="Email" error="Required" />);
const input = screen.getByLabelText('Email');
expect(input).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByText('Required')).toBeInTheDocument();
});
it('calls onChange with new value on typing', async () => {
const user = userEvent.setup();
const handleChange = jest.fn();
render(<TextInput label="Name" onChange={handleChange} />);
await user.type(screen.getByLabelText('Name'), 'Mayur');
expect(handleChange).toHaveBeenCalled();
});
it('disables the input when disabled prop is set', () => {
render(<TextInput label="Name" disabled />);
expect(screen.getByLabelText('Name')).toBeDisabled();
});
});
Documentation and the Developer Experience
A reusable component that's difficult to discover or understand won't be reused. Documentation is part of the component itself, not an afterthought.
Best practices for documenting reusable components:
- TypeScript interfaces: Types are live documentation that never goes stale. Define a
Propstype and export it alongside the component. - Storybook stories: Each story demonstrates one use case. Stories double as visual tests and a living style guide.
- JSDoc comments: Add a one-line description to each prop explaining what it does, not just its type.
- Usage examples in README: Copy-paste examples that work without modification reduce the time-to-first-use.
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual style of the button */
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
/** Size of the button */
size?: 'sm' | 'md' | 'lg';
/** Shows a loading spinner and disables interaction */
loading?: boolean;
/** Icon rendered before the button label */
leadingIcon?: React.ReactNode;
/** Icon rendered after the button label */
trailingIcon?: React.ReactNode;
}
Key Takeaways
- Design the API before writing the implementation — the props interface is the component's public contract
- Use
childrenover label props for maximum flexibility - Always spread
...restonto the underlying DOM element - Use
React.forwardRefon any component that wraps a form element - Compound components with Context eliminate prop drilling without sacrificing flexibility
- Custom hooks are the cleanest way to make stateful logic reusable
- Accessibility is not optional — bake in ARIA attributes from day one
- Test from the consumer's perspective using React Testing Library
Conclusion
Building reusable React components is a skill that compounds over time. Each well-designed component you add to your library saves hours of future work and reduces the surface area for bugs. The patterns covered here — flexible props APIs, compound components, render props, and custom hooks — aren't academic; they're the tools that production design systems like Radix UI, Headless UI, and React Aria are built on.
Start small: take one component you've duplicated in your codebase and apply these principles to consolidate it. Review the props you've exposed, check whether forwardRef is needed, and write at least three tests. That one component, done right, will teach you more than any tutorial.
As your component library grows, invest in tooling: TypeScript for prop safety, Storybook for discovery, and Chromatic or Percy for visual regression testing. The upfront investment pays dividends every time a teammate reaches for your component and everything just works.
