Building Reusable React Components
You build a Button for the checkout page. A month later someone needs it with an icon, so they add an icon prop. Then a loading prop, then iconPosition, then a copy of the whole file called ButtonV2 because the original got too scary to touch. Most React codebases have a component like this somewhere, and it usually didn't fail because of bad code. It failed because nobody thought about its API up front.
This post covers the patterns I reach for to avoid that: a props API built on defaults and ...rest, composition with compound components, custom hooks for shared logic, a complete accessible input, how to test it, and how to document it so people actually use it.
Don't abstract too early. If you've copied a component three times, that's usually the point to generalize it. Abstract after the first use and you end up guessing at requirements, which is how you get a component with 25 props and no clear job. Build once, duplicate once, then abstract on the third use.
What Makes a Component Reusable?
Avoiding duplicate code is the easy part. The hard part is drawing the right boundaries. A component that gets reused has one clear job, takes its configuration through props, and doesn't assume anything about where it sits in the tree (no reaching into a specific store, no hardcoded page margins).
What I look for in a component that's meant to be shared:
- 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
Once other people use your component, its props are a public contract, and changing them means touching every call site. You want an API that's easy to use correctly and hard to misuse. Native HTML elements are a good model here: <button> works with zero attributes and still accepts dozens of optional ones.
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>
);
}
The ...rest spread does a lot of work here. Any prop the component doesn't name goes straight onto the <button>, so consumers can pass aria-label, data-testid, id, or any other native attribute without you adding support for each one.
Two gotchas with it. First, order matters: {...rest} comes last, so anything in it overrides the attributes above. That's why className is destructured and merged by hand; leave it in rest and a consumer's class would silently wipe out your btn classes. Second, only spread props that are valid DOM attributes. If a custom prop like isActive leaks into rest, React logs Warning: React does not recognize the `isActive` prop on a DOM element. Destructure your custom props explicitly so they never reach the spread.
Prefer children Over Label Props
A label or text prop only takes a string, in practice. Use children instead and consumers can pass plain text, an icon plus text, or a spinner, all without changing the component.
// 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
When a component starts collecting props like showHeader, headerIcon and renderFooter, that's a sign it wants to be several components. Instead of one component with 30 props for every case, build small pieces that work together. HTML already does this with <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>
);
}
A Tabs.Tab only works inside a Tabs parent. Render one outside and the code above crashes with TypeError: Cannot destructure property 'activeTab' of ... as it is null, which tells the consumer nothing. Add a guard in each sub-component: if (!context) throw new Error('Tab must be used inside Tabs').
The Render Props Pattern
With render props, the component owns the state and the consumer decides what to render with it. Hooks have replaced most uses, but the pattern still shows up in components that render to a portal or need to hand render context down into 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
Sometimes two components look nothing alike but share the same behavior: a dropdown and a modal both need open/close state, a profile card and a settings page both fetch data. Pull that logic into a custom hook and each component keeps its own markup.
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>;
}
Don't skip the cancelled flag. Without it, if userId changes quickly, a slow response for the old user can land after the new one and overwrite it. That's a race condition you'll see on flaky mobile connections long before you see it on localhost. For real apps I'd still look at TanStack Query or SWR before growing this hook, since caching and retries get complicated fast.
Choosing the Right Pattern
Picking the wrong pattern adds complexity you don't need. Here's how they compare:
| 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
Here's a form input that pulls these ideas together. It handles what a design system input usually needs: a label, error state, helper text, icons, and proper accessibility wiring.
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.
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;
A few decisions worth pointing out. useId() gives stable IDs that match between server and client render, so you don't get hydration mismatches like you would with Math.random(). aria-describedby is only set when there's text to point at. The ...rest spread means every native input attribute works. And forwardRef lets form libraries like React Hook Form attach their ref to the real input. (In React 19, ref is a regular prop for function components, so you can drop forwardRef there.)
Testing Reusable Components
Shared components get used in places you never thought of, so tests matter more here than in page-level code. Test what the consumer sees and does, not internal state.
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
If nobody can find your component or figure out its props, they'll write their own. That's how you end up with three date pickers in one repo. Here's what helps:
- 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;
}
Mistakes I see most often
- Boolean prop pile-up:
isPrimary,isDanger,isGhoston the same button. Two of them can be true at once and nobody knows what wins. Use onevariantprop instead. - Forgetting
...rest: the first time someone needs adata-testidoraria-label, they fork the component. - No ref forwarding on inputs: focus management and form libraries quietly break.
- Abstracting after one use: you guess the API, guess wrong, and every later use fights it.
When to build reusable components (and when not to)
It's worth the effort for UI primitives that show up everywhere (buttons, inputs, modals, tabs) and for logic that keeps getting copied, like fetching or form state. For those, a clean API pays off every time someone reaches for the component instead of writing their own. Libraries like Radix UI, Headless UI and React Aria are built on these same patterns, so if you need complex widgets such as comboboxes or date pickers, I'd start from one of them rather than writing the accessibility logic yourself.
I wouldn't bother for one-off page sections, marketing layouts, or anything still changing every week. Making those "generic" mostly adds props nobody uses and makes the code harder to read. A bit of duplication is cheaper than the wrong abstraction, and you can always extract later once the third copy shows up.
If you want a place to start, pick one component that's been copied around your codebase, consolidate it with a props API like the one above, and write a few tests for it. TypeScript, Storybook and visual regression tools like Chromatic or Percy are worth adding once more than a couple of people use the library, but not before.