Build Reusable React Components: Patterns & Best Practices
Creating reusable React components is the cornerstone of maintainable, scalable applications. A well-designed component can be used across multiple projects, reducing code duplication and accelerating development. However, building truly reusable components requires understanding several design patterns that balance flexibility with type safety. This guide walks you through five battle-tested patterns that will transform how you design React components.
Table of Contents
- Understanding Reusable Component Design
- Generic Props Pattern
- Props Spreading Pattern
- Render Props Pattern
- Custom Hooks for Logic Extraction
- Controlled Component State
- Practical Example: Building a Data Table Component
- FAQ
Understanding Reusable Component Design
Reusable components share three fundamental characteristics. First, they accept configuration through props rather than hardcoding behavior. Second, they work with various data types without sacrificing type safety. Third, they expose extension points that allow consumers to customize rendering and behavior.
The challenge lies in finding the sweet spot between rigid components that do one thing well and flexible components that become unmaintainable. This guide helps you navigate that balance by introducing patterns that are battle-tested in production applications.
Generic Props Pattern
What Are Generic Props?
TypeScript generics allow your components to work with any data type while maintaining full type safety. This is particularly valuable when building list-based components, form components, or data display components that need to work with different shapes of data.
Without generics, you might hardcode a component to work with a specific type. With generics, the same component adapts to whatever data structure the consumer provides.
Implementing Generic Props
Let's build a reusable list component that renders any type of data:
TypeScript Version
import { ReactNode } from 'react';
interface ListProps<T> {
items: T[];
keyField: keyof T;
renderItem: (item: T) => ReactNode;
className?: string;
}
export function List<T>({
items,
keyField,
renderItem,
className = 'space-y-2',
}: ListProps<T>) {
return (
<ul className={className}>
{items.map((item) => (
<li key={String(item[keyField])} className="p-4 border rounded">
{renderItem(item)}
</li>
))}
</ul>
);
}
JavaScript Version
export function List({
items,
keyField,
renderItem,
className = 'space-y-2',
}) {
return (
<ul className={className}>
{items.map((item) => (
<li key={String(item[keyField])} className="p-4 border rounded">
{renderItem(item)}
</li>
))}
</ul>
);
}
Using Generic Components
The beauty of generics becomes clear when you use the same component with different data types:
// With a user type
interface User {
id: number;
name: string;
email: string;
}
function UsersList({ users }: { users: User[] }) {
return (
<List<User>
items={users}
keyField="id"
renderItem={(user) => (
<div>
<p className="font-semibold">{user.name}</p>
<p className="text-gray-600">{user.email}</p>
</div>
)}
/>
);
}
// With a different product type - same component!
interface Product {
sku: string;
title: string;
price: number;
}
function ProductsList({ products }: { products: Product[] }) {
return (
<List<Product>
items={products}
keyField="sku"
renderItem={(product) => (
<div>
<p className="font-semibold">{product.title}</p>
<p className="text-green-600">${product.price}</p>
</div>
)}
/>
);
}
Key insight: TypeScript enforces that keyField must be a valid property of the data type. If you try keyField="invalid", you'll get an immediate type error.
Props Spreading Pattern
Why Props Spreading Matters
HTML elements come with built-in props: className, id, onClick, disabled, and many others. When you wrap an element in a component, you often want to let consumers pass these native props through. Props spreading lets your component accept all standard HTML props without explicitly declaring each one.
Implementing Props Spreading
Here's a reusable form field component that combines a custom label with standard input behavior:
TypeScript Version
import { ComponentPropsWithoutRef, ReactNode } from 'react';
interface FieldProps extends ComponentPropsWithoutRef<'input'> {
label: ReactNode;
error?: string;
}
export function Field({
label,
error,
id,
...inputProps
}: FieldProps) {
const inputId = id || `field-${Math.random()}`;
return (
<div className="mb-4">
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 mb-1"
>
{label}
</label>
<input
{...inputProps}
id={inputId}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
error ? 'border-red-500' : 'border-gray-300'
}`}
/>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}
JavaScript Version
export function Field({
label,
error,
id,
...inputProps
}) {
const inputId = id || `field-${Math.random()}`;
return (
<div className="mb-4">
<label
htmlFor={inputId}
className="block text-sm font-medium text-gray-700 mb-1"
>
{label}
</label>
<input
{...inputProps}
id={inputId}
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
error ? 'border-red-500' : 'border-gray-300'
}`}
/>
{error && <p className="mt-1 text-sm text-red-600">{error}</p>}
</div>
);
}
Using Spread Props
Now consumers can use the Field component with all standard input props:
function LoginForm() {
const [email, setEmail] = React.useState('');
const [password, setPassword] = React.useState('');
return (
<form>
<Field
label="Email Address"
type="email"
value={email}
onChange={(e) => setEmail(e.currentTarget.value)}
placeholder="user@example.com"
required
error={email && !email.includes('@') ? 'Invalid email' : undefined}
/>
<Field
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
minLength={8}
required
/>
</form>
);
}
Best practice: The spread operator (...inputProps) goes directly onto the HTML element. This preserves the element's native behavior while letting your component add custom styling and validation logic.
Render Props Pattern
Understanding Render Props
The render props pattern inverts control over rendering. Instead of the component deciding how to display data, you give that responsibility to the consumer through a function prop. This is incredibly powerful for building flexible, reusable components.
Building a Checkbox List with Render Props
Let's create a component that manages checkbox state while letting consumers control how items appear:
TypeScript Version
import { ReactNode, useState } from 'react';
interface ChecklistProps<T> {
items: T[];
itemId: keyof T;
renderItem: (item: T, isChecked: boolean) => ReactNode;
onSelectionChange?: (selectedIds: string[]) => void;
}
export function Checklist<T>({
items,
itemId,
renderItem,
onSelectionChange,
}: ChecklistProps<T>) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const handleToggle = (id: string) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedIds(newSelected);
onSelectionChange?.(Array.from(newSelected));
};
return (
<div className="space-y-2">
{items.map((item) => {
const id = String(item[itemId]);
const isChecked = selectedIds.has(id);
return (
<label
key={id}
className="flex items-center p-3 border rounded-lg hover:bg-gray-50 cursor-pointer"
>
<input
type="checkbox"
checked={isChecked}
onChange={() => handleToggle(id)}
className="w-4 h-4 rounded border-gray-300"
/>
<div className="ml-3">{renderItem(item, isChecked)}</div>
</label>
);
})}
</div>
);
}
JavaScript Version
import { useState } from 'react';
export function Checklist({
items,
itemId,
renderItem,
onSelectionChange,
}) {
const [selectedIds, setSelectedIds] = useState(new Set());
const handleToggle = (id) => {
const newSelected = new Set(selectedIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
setSelectedIds(newSelected);
onSelectionChange?.(Array.from(newSelected));
};
return (
<div className="space-y-2">
{items.map((item) => {
const id = String(item[itemId]);
const isChecked = selectedIds.has(id);
return (
<label
key={id}
className="flex items-center p-3 border rounded-lg hover:bg-gray-50 cursor-pointer"
>
<input
type="checkbox"
checked={isChecked}
onChange={() => handleToggle(id)}
className="w-4 h-4 rounded border-gray-300"
/>
<div className="ml-3">{renderItem(item, isChecked)}</div>
</label>
);
})}
</div>
);
}
Using Render Props for Custom Display
interface Permission {
id: string;
name: string;
description: string;
category: 'user' | 'admin' | 'system';
}
function PermissionsSelector({ permissions }: { permissions: Permission[] }) {
return (
<Checklist<Permission>
items={permissions}
itemId="id"
renderItem={(permission, isChecked) => (
<div className="flex-1">
<p className="font-medium text-gray-900">{permission.name}</p>
<p className="text-sm text-gray-500">{permission.description}</p>
<span
className={`inline-block mt-1 px-2 py-1 text-xs rounded ${
permission.category === 'system'
? 'bg-red-100 text-red-800'
: permission.category === 'admin'
? 'bg-blue-100 text-blue-800'
: 'bg-green-100 text-green-800'
}`}
>
{permission.category}
</span>
</div>
)}
onSelectionChange={(ids) => console.log('Selected:', ids)}
/>
);
}
Custom Hooks for Logic Extraction
Why Extract Logic into Hooks
As components grow, their logic becomes complex. Custom hooks let you extract stateful logic and reuse it across components. This keeps individual components focused on rendering while keeping business logic testable and reusable.
Building a useAsync Hook
Here's a practical custom hook for handling asynchronous operations:
TypeScript Version
import { useEffect, useState, useCallback } from 'react';
interface UseAsyncState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export function useAsync<T>(
asyncFunction: () => Promise<T>,
immediate = true
) {
const [state, setState] = useState<UseAsyncState<T>>({
data: null,
loading: immediate,
error: null,
});
const execute = useCallback(async () => {
setState({ data: null, loading: true, error: null });
try {
const result = await asyncFunction();
setState({ data: result, loading: false, error: null });
} catch (error) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error : new Error('Unknown error'),
});
}
}, [asyncFunction]);
useEffect(() => {
if (immediate) {
execute();
}
}, [execute, immediate]);
return { ...state, execute };
}
JavaScript Version
import { useEffect, useState, useCallback } from 'react';
export function useAsync(asyncFunction, immediate = true) {
const [state, setState] = useState({
data: null,
loading: immediate,
error: null,
});
const execute = useCallback(async () => {
setState({ data: null, loading: true, error: null });
try {
const result = await asyncFunction();
setState({ data: result, loading: false, error: null });
} catch (error) {
setState({
data: null,
loading: false,
error: error instanceof Error ? error : new Error('Unknown error'),
});
}
}, [asyncFunction]);
useEffect(() => {
if (immediate) {
execute();
}
}, [execute, immediate]);
return { ...state, execute };
}
Using the Custom Hook
interface GitHubUser {
login: string;
avatar_url: string;
bio: string;
}
function UserProfile({ username }: { username: string }) {
const { data: user, loading, error, execute } = useAsync<GitHubUser>(
() => fetch(`https://api.github.com/users/${username}`).then(r => r.json()),
true
);
return (
<div className="p-6 border rounded-lg">
{loading && <p>Loading...</p>}
{error && <p className="text-red-600">Error: {error.message}</p>}
{user && (
<div className="flex items-start gap-4">
<img
src={user.avatar_url}
alt={user.login}
className="w-16 h-16 rounded-full"
/>
<div>
<h2 className="text-xl font-bold">{user.login}</h2>
<p className="text-gray-600">{user.bio}</p>
<button
onClick={() => execute()}
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded"
>
Refresh
</button>
</div>
</div>
)}
</div>
);
}
Controlled Component State
What Are Controlled Components?
A controlled component gets its state from props and notifies the parent when state should change. This pattern gives parents control over the component's behavior, which is essential for complex applications where multiple components need to stay in sync.
Building a Controlled Input Component
TypeScript Version
import { useState, useEffect } from 'react';
interface ControlledInputProps {
value: string;
onChange: (newValue: string) => void;
placeholder?: string;
disabled?: boolean;
validator?: (value: string) => string | null;
}
export function ControlledInput({
value,
onChange,
placeholder,
disabled = false,
validator,
}: ControlledInputProps) {
const [internalValue, setInternalValue] = useState(value);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setInternalValue(value);
if (validator) {
setError(validator(value));
}
}, [value, validator]);
const handleChange = (newValue: string) => {
setInternalValue(newValue);
onChange(newValue);
if (validator) {
setError(validator(newValue));
}
};
return (
<div>
<input
type="text"
value={internalValue}
onChange={(e) => handleChange(e.currentTarget.value)}
placeholder={placeholder}
disabled={disabled}
className={`w-full px-3 py-2 border rounded ${
error ? 'border-red-500' : 'border-gray-300'
}`}
/>
{error && <p className="text-red-600 text-sm mt-1">{error}</p>}
</div>
);
}
JavaScript Version
import { useState, useEffect } from 'react';
export function ControlledInput({
value,
onChange,
placeholder,
disabled = false,
validator,
}) {
const [internalValue, setInternalValue] = useState(value);
const [error, setError] = useState(null);
useEffect(() => {
setInternalValue(value);
if (validator) {
setError(validator(value));
}
}, [value, validator]);
const handleChange = (newValue) => {
setInternalValue(newValue);
onChange(newValue);
if (validator) {
setError(validator(newValue));
}
};
return (
<div>
<input
type="text"
value={internalValue}
onChange={(e) => handleChange(e.currentTarget.value)}
placeholder={placeholder}
disabled={disabled}
className={`w-full px-3 py-2 border rounded ${
error ? 'border-red-500' : 'border-gray-300'
}`}
/>
{error && <p className="text-red-600 text-sm mt-1">{error}</p>}
</div>
);
}
Practical Example: Building a Data Table Component
Let's combine all these patterns to build a production-ready data table component:
TypeScript Version
import { ReactNode, useState } from 'react';
interface Column<T> {
key: keyof T;
label: string;
render?: (value: T[keyof T], item: T) => ReactNode;
}
interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
keyField: keyof T;
onRowClick?: (item: T) => void;
sortableColumns?: (keyof T)[];
}
export function DataTable<T>({
data,
columns,
keyField,
onRowClick,
sortableColumns = [],
}: DataTableProps<T>) {
const [sortConfig, setSortConfig] = useState<{
key: keyof T;
direction: 'asc' | 'desc';
} | null>(null);
const sortedData = [...data].sort((a, b) => {
if (!sortConfig) return 0;
const aVal = a[sortConfig.key];
const bVal = b[sortConfig.key];
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
const handleSort = (key: keyof T) => {
if (!sortableColumns.includes(key)) return;
setSortConfig(
!sortConfig || sortConfig.key !== key
? { key, direction: 'asc' }
: {
key,
direction: sortConfig.direction === 'asc' ? 'desc' : 'asc',
}
);
};
return (
<table className="w-full border-collapse">
<thead className="bg-gray-100">
<tr>
{columns.map((column) => (
<th
key={String(column.key)}
onClick={() => handleSort(column.key)}
className={`p-3 text-left font-semibold ${
sortableColumns.includes(column.key)
? 'cursor-pointer hover:bg-gray-200'
: ''
}`}
>
{column.label}
{sortConfig?.key === column.key && (
<span className="ml-2">
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{sortedData.map((item) => (
<tr
key={String(item[keyField])}
onClick={() => onRowClick?.(item)}
className="border-b hover:bg-gray-50 cursor-pointer"
>
{columns.map((column) => (
<td key={String(column.key)} className="p-3">
{column.render
? column.render(item[column.key], item)
: String(item[column.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
JavaScript Version
import { useState } from 'react';
export function DataTable({
data,
columns,
keyField,
onRowClick,
sortableColumns = [],
}) {
const [sortConfig, setSortConfig] = useState(null);
const sortedData = [...data].sort((a, b) => {
if (!sortConfig) return 0;
const aVal = a[sortConfig.key];
const bVal = b[sortConfig.key];
if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
return 0;
});
const handleSort = (key) => {
if (!sortableColumns.includes(key)) return;
setSortConfig(
!sortConfig || sortConfig.key !== key
? { key, direction: 'asc' }
: {
key,
direction: sortConfig.direction === 'asc' ? 'desc' : 'asc',
}
);
};
return (
<table className="w-full border-collapse">
<thead className="bg-gray-100">
<tr>
{columns.map((column) => (
<th
key={String(column.key)}
onClick={() => handleSort(column.key)}
className={`p-3 text-left font-semibold ${
sortableColumns.includes(column.key)
? 'cursor-pointer hover:bg-gray-200'
: ''
}`}
>
{column.label}
{sortConfig?.key === column.key && (
<span className="ml-2">
{sortConfig.direction === 'asc' ? '↑' : '↓'}
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{sortedData.map((item) => (
<tr
key={String(item[keyField])}
onClick={() => onRowClick?.(item)}
className="border-b hover:bg-gray-50 cursor-pointer"
>
{columns.map((column) => (
<td key={String(column.key)} className="p-3">
{column.render
? column.render(item[column.key], item)
: String(item[column.key])}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
Using the Data Table
interface Employee {
id: number;
name: string;
department: string;
salary: number;
joinDate: string;
}
const employees: Employee[] = [
{ id: 1, name: 'Alice Chen', department: 'Engineering', salary: 120000, joinDate: '2021-01-15' },
{ id: 2, name: 'Bob Smith', department: 'Design', salary: 95000, joinDate: '2022-03-20' },
];
function EmployeeDirectory() {
return (
<DataTable<Employee>
data={employees}
keyField="id"
columns={[
{ key: 'name', label: 'Name' },
{ key: 'department', label: 'Department' },
{
key: 'salary',
label: 'Salary',
render: (salary) => `$${Number(salary).toLocaleString()}`,
},
{ key: 'joinDate', label: 'Join Date' },
]}
sortableColumns={['name', 'salary', 'joinDate']}
onRowClick={(employee) => console.log('Selected:', employee)}
/>
);
}
Comparison of Reusable Component Patterns
| Pattern | Best For | Pros | Cons |
|---|---|---|---|
| Generic Props | Type-safe data handling | Strong typing, works with any data | Requires TypeScript knowledge |
| Props Spreading | HTML element wrapping | Inherits all native props | Can pass unintended props through |
| Render Props | Custom rendering logic | Maximum flexibility | Can become complex with many renders |
| Custom Hooks | Stateful logic extraction | Reusable across components | Harder to debug with multiple hooks |
| Controlled State | Parent-child synchronization | Single source of truth | More boilerplate code |
FAQ
Q: When should I use render props vs. the children prop?
A: Use the children prop when you want to pass content that isn't data-dependent. Use render props when you need to pass the component's internal data to the renderer function. For example, a List component with renderItem needs to pass each item to the renderer, while a Modal can just use children.
Q: Is it better to pass all HTML props through or be selective?
A: Generally, be selective. While props spreading gives maximum flexibility, it can allow consumers to pass props that break your component's design (like conflicting className values). Consider intersecting only the props you want to support.
Q: How do I prevent prop drilling with reusable components?
A: Custom hooks combined with React Context are your best friends. Extract logic into a custom hook that uses Context internally, and export both the hook and provider. This lets consumers access data without passing props through every level.
Q: Can I make a component both controlled and uncontrolled?
A: Yes, this is called an "uncontrolled with callback" pattern. Check if the value prop is provided—if yes, use controlled mode; if no, manage state internally. However, this can be confusing. It's usually clearer to create separate controlled and uncontrolled variants.
Q: Should I always use TypeScript for reusable components?
A: For public or shared components, TypeScript dramatically improves the developer experience. For internal components, JavaScript might be sufficient if your team is small. However, even with JavaScript, using JSDoc comments for type hints is valuable.
Key Takeaway: The most reusable components combine multiple patterns—using generics for type safety, props spreading for flexibility, render props for customization, custom hooks for logic, and controlled state for parent control. Master these patterns, and you'll build components that are both powerful and a pleasure to use.
Questions? Share your component design challenges in the comments below. What patterns have you found most valuable in your projects?
Google AdSense Placeholder
CONTENT SLOT