Overview
Building reusable components is essential for maintaining a scalable frontend codebase. This guide covers component design patterns, composition strategies, and best practices.
Component Design Principles
Single Responsibility
Each component should do one thing well. If a component becomes too complex, break it into smaller pieces.
Props Interface
Design clear, typed interfaces for your component props. This makes components self-documenting and easier to use.
Composition Over Configuration
Prefer composing smaller components rather than creating large, configurable components with many props.
Creating a Basic Component
import { ReactNode } from 'react';
interface ButtonProps {
children: ReactNode;
variant?: 'primary' | 'secondary' | 'outline';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
onClick?: () => void;
}
export function Button({
children,
variant = 'primary',
size = 'md',
disabled = false,
onClick
}: ButtonProps) {
const baseStyles = 'rounded font-medium transition-colors focus:outline-none focus:ring-2';
const variantStyles = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500',
outline: 'border-2 border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-blue-500'
};
const sizeStyles = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
};
return (
<button
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]}`}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
);
}
Use TypeScript interfaces to define props. This provides autocomplete and type safety when using components.
Compound Components Pattern
Compound components work together to form a complete UI element. This pattern provides flexibility while maintaining a clean API.
import { ReactNode, createContext, useContext } from 'react';
interface CardContextValue {
variant: 'default' | 'elevated' | 'outlined';
}
const CardContext = createContext<CardContextValue>({ variant: 'default' });
interface CardProps {
children: ReactNode;
variant?: 'default' | 'elevated' | 'outlined';
className?: string;
}
export function Card({ children, variant = 'default', className = '' }: CardProps) {
const variantStyles = {
default: 'bg-white border border-gray-200',
elevated: 'bg-white shadow-lg',
outlined: 'bg-transparent border-2 border-gray-300'
};
return (
<CardContext.Provider value={{ variant }}>
<div className={`rounded-lg ${variantStyles[variant]} ${className}`}>
{children}
</div>
</CardContext.Provider>
);
}
interface CardHeaderProps {
children: ReactNode;
className?: string;
}
Card.Header = function CardHeader({ children, className = '' }: CardHeaderProps) {
return (
<div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
{children}
</div>
);
};
interface CardBodyProps {
children: ReactNode;
className?: string;
}
Card.Body = function CardBody({ children, className = '' }: CardBodyProps) {
return (
<div className={`px-6 py-4 ${className}`}>
{children}
</div>
);
};
interface CardFooterProps {
children: ReactNode;
className?: string;
}
Card.Footer = function CardFooter({ children, className = '' }: CardFooterProps) {
return (
<div className={`px-6 py-4 border-t border-gray-200 ${className}`}>
{children}
</div>
);
};
Compound components provide a flexible API that’s easy to understand and use. Users can compose the parts they need without dealing with complex prop configurations.
Render Props Pattern
Render props allow you to share logic between components while giving consumers full control over rendering.
import { ReactNode, useEffect, useState } from 'react';
interface DataLoaderProps<T> {
url: string;
children: (data: {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}) => ReactNode;
}
export function DataLoader<T>({ url, children }: DataLoaderProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch');
const result = await response.json();
setData(result);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, [url]);
return <>{children({ data, loading, error, refetch: fetchData })}</>;
}
Custom Hooks for Reusable Logic
Extract component logic into custom hooks for maximum reusability.
import { useState, useCallback } from 'react';
export 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 };
}
import { ReactNode } from 'react';
import { useToggle } from './useToggle';
interface ModalProps {
trigger: (open: () => void) => ReactNode;
children: ReactNode;
title: string;
}
export function Modal({ trigger, children, title }: ModalProps) {
const { value: isOpen, setTrue: open, setFalse: close } = useToggle();
if (!isOpen) return <>{trigger(open)}</>;
return (
<>
{trigger(open)}
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 max-w-md w-full">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">{title}</h2>
<button onClick={close} className="text-gray-500 hover:text-gray-700">
✕
</button>
</div>
{children}
</div>
</div>
</>
);
}
Avoid creating too many abstraction layers. Sometimes a simple component is better than a highly abstracted one. Balance reusability with simplicity.
Component Testing
Test components to ensure they work as expected and remain stable during refactoring.
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('does not call onClick when disabled', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick} disabled>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).not.toHaveBeenCalled();
});
it('applies correct variant styles', () => {
const { rerender } = render(<Button variant="primary">Button</Button>);
const button = screen.getByText('Button');
expect(button).toHaveClass('bg-blue-600');
rerender(<Button variant="outline">Button</Button>);
expect(button).toHaveClass('border-2');
});
});
Best Practices
Next Steps