Creating Reusable UI Components in React

In modern web development, building reusable UI components is essential for maintaining consistency, improving developer efficiency, and creating scalable applications. React, with its component-based architecture, provides an excellent foundation for creating reusable UI elements. This article explores best practices, patterns, and techniques for designing highly reusable React components.
Why Reusable Components Matter
Reusable components offer several advantages:
- Consistency: Ensure UI elements look and behave consistently across your application
- Efficiency: Write code once and use it throughout your project
- Maintainability: Fix bugs or update features in one place rather than multiple locations
- Collaboration: Enable teams to work more effectively with standardized building blocks
- Testing: Simplify testing with isolated, well-defined components
Core Principles of Reusable Components
1. Single Responsibility
Each component should do one thing and do it well. For example, a Button
component should handle rendering a button with various states, not managing authentication logic or API calls.
// Good: A button component focused on rendering
const Button = ({ children, variant = 'primary', size = 'medium', onClick }) => {
return (
<button
className={`btn btn-${variant} btn-${size}`}
onClick={onClick}
>
{children}
</button>
);
};
2. Configurable Props
Make components adaptable through props with sensible defaults:
const Card = ({
title,
children,
elevation = 1,
headerComponent = null,
footerComponent = null
}) => {
return (
<div className={`card elevation-${elevation}`}>
{headerComponent || (
<div className="card-header">
<h3>{title}</h3>
</div>
)}
<div className="card-body">
{children}
</div>
{footerComponent && (
<div className="card-footer">
{footerComponent}
</div>
)}
</div>
);
};
3. Composition Over Complex Configuration
Instead of creating overly complex components with numerous props, leverage React’s composability:
// Instead of a complex Table component with many configuration options:
<Table
data={data}
columns={columns}
sortable={true}
paginated={true}
pageSize={10}
// ... many more props
/>
// Consider a composable approach:
<Table data={data}>
<TableHeader>
<TableColumn sortable>Name</TableColumn>
<TableColumn>Email</TableColumn>
<TableColumn>Role</TableColumn>
</TableHeader>
<TableBody />
<TablePagination pageSize={10} />
</Table>
Advanced Patterns for Reusability
1. Component API Design
When designing your component’s API (the props it accepts), consider these guidelines:
- Consistent naming: Use consistent prop names across components (e.g., always use
onChange
nothandleChange
in some components) - Boolean props: Use positive naming for boolean props (
isEnabled
instead ofisDisabled
) - Required vs. optional: Make as many props optional as possible with sensible defaults
- Documentation: Use PropTypes or TypeScript to document your component’s API
import PropTypes from 'prop-types';
const Dropdown = ({
options,
selectedValue,
onChange,
placeholder = 'Select an option',
disabled = false,
size = 'medium'
}) => {
// Component implementation
};
Dropdown.propTypes = {
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.string.isRequired,
label: PropTypes.string.isRequired
})
).isRequired,
selectedValue: PropTypes.string,
onChange: PropTypes.func.isRequired,
placeholder: PropTypes.string,
disabled: PropTypes.bool,
size: PropTypes.oneOf(['small', 'medium', 'large'])
};
2. Compound Components Pattern
The compound component pattern creates a set of components that work together but can be composed flexibly:
const Tabs = ({ children, defaultActiveTab = 0 }) => {
const [activeTab, setActiveTab] = useState(defaultActiveTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs-container">
{children}
</div>
</TabsContext.Provider>
);
};
const TabList = ({ children }) => {
return <div className="tab-list">{children}</div>;
};
const Tab = ({ children, index }) => {
const { activeTab, setActiveTab } = useContext(TabsContext);
return (
<div
className={`tab ${activeTab === index ? 'active' : ''}`}
onClick={() => setActiveTab(index)}
>
{children}
</div>
);
};
const TabPanels = ({ children }) => {
const { activeTab } = useContext(TabsContext);
return <div className="tab-panels">{children[activeTab]}</div>;
};
const TabPanel = ({ children }) => {
return <div className="tab-panel">{children}</div>;
};
// Usage
<Tabs>
<TabList>
<Tab index={0}>Home</Tab>
<Tab index={1}>Profile</Tab>
<Tab index={2}>Settings</Tab>
</TabList>
<TabPanels>
<TabPanel>Home content</TabPanel>
<TabPanel>Profile content</TabPanel>
<TabPanel>Settings content</TabPanel>
</TabPanels>
</Tabs>
3. Render Props Pattern
The render props pattern allows a component to share its internal state with child components through a function prop:
const Toggle = ({ children, initialState = false }) => {
const [isOn, setIsOn] = useState(initialState);
const toggle = () => setIsOn(!isOn);
return children({ isOn, toggle });
};
// Usage
<Toggle>
{({ isOn, toggle }) => (
<div>
<button onClick={toggle}>
{isOn ? 'Turn Off' : 'Turn On'}
</button>
<div>{isOn ? 'The toggle is on' : 'The toggle is off'}</div>
</div>
)}
</Toggle>
4. Custom Hooks for Reusable Logic
Extract reusable logic into custom hooks:
// Instead of repeating form logic in multiple components
function useForm(initialValues) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setValues({ ...values, [name]: value });
};
const validate = (schema) => {
// Validation logic here
};
const handleSubmit = (onSubmit) => (e) => {
e.preventDefault();
onSubmit(values);
};
return { values, errors, handleChange, validate, handleSubmit };
}
// Usage in components
function LoginForm() {
const { values, handleChange, handleSubmit } = useForm({
email: '',
password: ''
});
return (
<form onSubmit={handleSubmit(loginUser)}>
<input
name="email"
value={values.email}
onChange={handleChange}
/>
<input
type="password"
name="password"
value={values.password}
onChange={handleChange}
/>
<button type="submit">Login</button>
</form>
);
}
Styling Reusable Components
Component-Specific Styling
Several approaches can be used for styling reusable components:
- CSS Modules:
import styles from './Button.module.css';
const Button = ({ children, type = 'primary' }) => (
<button className={`${styles.button} ${styles[type]}`}>
{children}
</button>
);
- Styled Components:
import styled from 'styled-components';
const StyledButton = styled.button`
border-radius: 4px;
padding: 8px 16px;
background-color: ${props => props.primary ? 'blue' : 'white'};
color: ${props => props.primary ? 'white' : 'blue'};
`;
const Button = ({ children, primary = false }) => (
<StyledButton primary={primary}>{children}</StyledButton>
);
- Tailwind CSS:
const Button = ({ children, variant = 'primary' }) => {
const variantClasses = {
primary: 'bg-blue-500 hover:bg-blue-700 text-white',
secondary: 'bg-gray-200 hover:bg-gray-300 text-gray-800',
danger: 'bg-red-500 hover:bg-red-700 text-white'
};
return (
<button className={`px-4 py-2 rounded font-bold ${variantClasses[variant]}`}>
{children}
</button>
);
};
Theming
For consistent styling across all components, implement a theming system:
import { ThemeProvider, createTheme } from '@mui/material/styles';
const theme = createTheme({
palette: {
primary: {
main: '#1976d2',
},
secondary: {
main: '#dc004e',
},
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
button: {
textTransform: 'none',
},
},
shape: {
borderRadius: 8,
},
});
// App root
const App = () => (
<ThemeProvider theme={theme}>
<YourApplication />
</ThemeProvider>
);
Testing Reusable Components
Well-tested components are more reliable and easier to reuse:
// Button.test.jsx
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';
describe('Button component', () => {
test('renders with correct text', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
test('calls onClick handler when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('renders with primary variant by default', () => {
render(<Button>Click me</Button>);
const button = screen.getByText('Click me');
expect(button).toHaveClass('btn-primary');
});
test('renders with specified variant', () => {
render(<Button variant="secondary">Click me</Button>);
const button = screen.getByText('Click me');
expect(button).toHaveClass('btn-secondary');
});
});
Documentation with Storybook
Storybook is an excellent tool for documenting your reusable components:
// Button.stories.jsx
import Button from './Button';
export default {
title: 'Components/Button',
component: Button,
argTypes: {
variant: {
control: { type: 'select', options: ['primary', 'secondary', 'danger'] },
},
size: {
control: { type: 'select', options: ['small', 'medium', 'large'] },
},
},
};
const Template = (args) => <Button {...args} />;
export const Primary = Template.bind({});
Primary.args = {
variant: 'primary',
children: 'Primary Button',
};
export const Secondary = Template.bind({});
Secondary.args = {
variant: 'secondary',
children: 'Secondary Button',
};
export const Small = Template.bind({});
Small.args = {
size: 'small',
children: 'Small Button',
};
Component Libraries Comparison
Library | Pros | Cons |
---|---|---|
MUI (Material-UI) | Rich component set, customizable theming, good docs | Large bundle size, opinionated styling |
Chakra UI | Accessible by default, easy theming, composable | Newer library, smaller community |
Ant Design | Enterprise-ready, comprehensive, well tested | Heavyweight, opinionated design |
Tailwind UI | Highly customizable, no runtime CSS | Learning curve, verbose class names |
Best Practices Summary
- Component Granularity: Find the right balance between too granular (button, label) and too large (entire form)
- Prop Naming: Use consistent, clear prop names
- Default Props: Provide sensible defaults whenever possible
- Documentation: Document your components with PropTypes/TypeScript and examples
- Testing: Write tests for all component scenarios
- Accessibility: Build with accessibility in mind from the start
- Performance: Optimize components with memoization (React.memo, useMemo) when necessary
- Package Structure: Organize components in a logical folder structure
- Version Control: Consider using semantic versioning if publishing as a library
Conclusion
Building reusable UI components in React requires thoughtful design, consistent patterns, and attention to detail. By following these best practices, you can create a component library that enhances consistency, accelerates development, and improves the overall quality of your React applications.
When implemented correctly, a well-designed component system becomes an invaluable asset, allowing your team to build and iterate faster while maintaining a cohesive user experience.
How to use Bootstrap’s responsive embed classes for videos
How to create a responsive contact form with Bootstrap
How to use Bootstrap’s utilities for hiding and showing elements
How to implement a sticky footer with a content area that scrolls