Mastering Component Architecture - Building Scalable React Applications
Learn how to design and implement robust component architectures that scale with your team and project requirements.
By Patrick Prunty

Building scalable React applications requires more than just knowing how to write components—it demands a deep understanding of component architecture and design patterns.
The Foundation of Component Architecture
Great component architecture is like a well-designed building: it needs a solid foundation, clear structure, and room for future expansion.
Core Principles
- Single Responsibility: Each component should have one clear purpose
- Composability: Components should work well together
- Reusability: Write once, use everywhere (with variations)
- Maintainability: Code should be easy to understand and modify
Component Hierarchy Patterns
Understanding how to structure your component tree is crucial for maintainability.
// Bad: Monolithic component
function BadUserDashboard({ user }) {
return (
<div className="dashboard">
<header>
<img src={user.avatar} />
<h1>{user.name}</h1>
<span>{user.role}</span>
</header>
<nav>
<a href="/profile">Profile</a>
<a href="/settings">Settings</a>
<a href="/billing">Billing</a>
</nav>
<main>
<div className="stats">
<div>Projects: {user.projects.length}</div>
<div>Tasks: {user.tasks.length}</div>
</div>
<div className="recent-activity">
{user.recentActivity.map(activity => (
<div key={activity.id}>
{activity.type}: {activity.description}
</div>
))}
</div>
</main>
</div>
);
}
// Good: Composed architecture
function UserDashboard({ user }) {
return (
<DashboardLayout>
<DashboardHeader user={user} />
<DashboardNavigation />
<DashboardContent>
<UserStats stats={user.stats} />
<RecentActivity activities={user.recentActivity} />
</DashboardContent>
</DashboardLayout>
);
}Compound Components Pattern
This pattern allows components to work together while maintaining clean APIs.
// Modal compound component
interface ModalContextType {
isOpen: boolean;
onClose: () => void;
}
const ModalContext = createContext<ModalContextType | null>(null);
function Modal({ children, isOpen, onClose }) {
return (
<ModalContext.Provider value={{ isOpen, onClose }}>
{isOpen && (
<div className="modal-overlay" onClick={onClose}>
{children}
</div>
)}
</ModalContext.Provider>
);
}
function ModalHeader({ children, className = "" }) {
return (
<div className={`modal-header ${className}`}>
{children}
</div>
);
}
function ModalBody({ children, className = "" }) {
return (
<div className={`modal-body ${className}`}>
{children}
</div>
);
}
function ModalFooter({ children, className = "" }) {
const { onClose } = useContext(ModalContext);
return (
<div className={`modal-footer ${className}`}>
{children}
</div>
);
}
function ModalCloseButton({ children = "Ă—", className = "" }) {
const { onClose } = useContext(ModalContext);
return (
<button
className={`modal-close ${className}`}
onClick={onClose}
>
{children}
</button>
);
}
// Attach sub-components to main component
Modal.Header = ModalHeader;
Modal.Body = ModalBody;
Modal.Footer = ModalFooter;
Modal.CloseButton = ModalCloseButton;
// Usage
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
return (
<>
<button onClick={() => setIsModalOpen(true)}>
Open Modal
</button>
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
<Modal.Header>
<h2>Confirm Action</h2>
<Modal.CloseButton />
</Modal.Header>
<Modal.Body>
<p>Are you sure you want to proceed?</p>
</Modal.Body>
<Modal.Footer>
<button onClick={() => setIsModalOpen(false)}>
Cancel
</button>
<button onClick={() => console.log('Confirmed')}>
Confirm
</button>
</Modal.Footer>
</Modal>
</>
);
}Render Props and Children Patterns
These patterns provide flexibility while maintaining reusability.
// Data fetcher with render props
interface DataFetcherProps<T> {
url: string;
children: (data: {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}) => React.ReactNode;
}
function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(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);
}
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
return children({ data, loading, error, refetch: fetchData });
}
// Usage with different render strategies
function UserList() {
return (
<DataFetcher<User[]> url="/api/users">
{({ data: users, loading, error, refetch }) => {
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
if (!users?.length) return <EmptyState />;
return (
<div className="user-list">
{users.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}}
</DataFetcher>
);
}
// Function as children pattern
interface ToggleProps {
children: (isOn: boolean, toggle: () => void) => React.ReactNode;
defaultOn?: boolean;
}
function Toggle({ children, defaultOn = false }: ToggleProps) {
const [isOn, setIsOn] = useState(defaultOn);
const toggle = () => setIsOn(prev => !prev);
return <>{children(isOn, toggle)}</>;
}
// Usage
function App() {
return (
<Toggle>
{(isOn, toggle) => (
<div>
<button onClick={toggle}>
{isOn ? 'Turn Off' : 'Turn On'}
</button>
{isOn && <div>The light is on!</div>}
</div>
)}
</Toggle>
);
}Higher-Order Components (HOCs)
HOCs provide a way to share logic between components.
// Authentication HOC
function withAuth<P extends object>(
WrappedComponent: React.ComponentType<P>
) {
return function WithAuthComponent(props: P) {
const { user, loading } = useAuth();
if (loading) {
return <LoadingSpinner />;
}
if (!user) {
return <LoginForm />;
}
return <WrappedComponent {...props} user={user} />;
};
}
// Error boundary HOC
function withErrorBoundary<P extends object>(
WrappedComponent: React.ComponentType<P>,
fallback?: React.ComponentType<{ error: Error; resetError: () => void }>
) {
return function WithErrorBoundaryComponent(props: P) {
return (
<ErrorBoundary fallback={fallback}>
<WrappedComponent {...props} />
</ErrorBoundary>
);
};
}
// Performance monitoring HOC
function withPerformanceMonitoring<P extends object>(
WrappedComponent: React.ComponentType<P>,
componentName: string
) {
return function WithPerformanceComponent(props: P) {
useEffect(() => {
const startTime = performance.now();
return () => {
const endTime = performance.now();
console.log(`${componentName} render time: ${endTime - startTime}ms`);
};
});
return <WrappedComponent {...props} />;
};
}
// Compose multiple HOCs
const enhance = compose(
withAuth,
withErrorBoundary,
withPerformanceMonitoring('UserDashboard')
);
const EnhancedUserDashboard = enhance(UserDashboard);Custom Hooks for Logic Reuse
Custom hooks extract component logic into reusable functions.
// Local storage hook
function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error('Error reading localStorage:', error);
return initialValue;
}
});
const setValue = (value: T | ((prev: T) => T)) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error('Error setting localStorage:', error);
}
};
return [storedValue, setValue];
}
// Debounced value hook
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// API hook with caching
function useAPI<T>(url: string, options?: RequestInit) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const cacheKey = useMemo(() =>
`${url}-${JSON.stringify(options)}`, [url, options]
);
useEffect(() => {
let cancelled = false;
async function fetchData() {
try {
setLoading(true);
setError(null);
// Check cache first
const cached = sessionStorage.getItem(cacheKey);
if (cached) {
const { data: cachedData, timestamp } = JSON.parse(cached);
const isStale = Date.now() - timestamp > 5 * 60 * 1000; // 5 minutes
if (!isStale) {
setData(cachedData);
setLoading(false);
return;
}
}
const response = await fetch(url, options);
if (!response.ok) throw new Error('Network response was not ok');
const result = await response.json();
if (!cancelled) {
setData(result);
// Cache the result
sessionStorage.setItem(cacheKey, JSON.stringify({
data: result,
timestamp: Date.now()
}));
}
} catch (err) {
if (!cancelled) {
setError(err as Error);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
fetchData();
return () => {
cancelled = true;
};
}, [url, options, cacheKey]);
return { data, loading, error };
}
// Usage in components
function SearchResults() {
const [query, setQuery] = useLocalStorage('searchQuery', '');
const debouncedQuery = useDebounce(query, 300);
const { data: results, loading, error } = useAPI<SearchResult[]>(
`/api/search?q=${encodeURIComponent(debouncedQuery)}`
);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <LoadingSpinner />}
{error && <ErrorMessage error={error} />}
{results && (
<div className="results">
{results.map(result => (
<SearchResultItem key={result.id} result={result} />
))}
</div>
)}
</div>
);
}Component Testing Strategies
Testing component architecture ensures reliability and maintainability.
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// Test utilities
function renderWithProviders(ui: React.ReactElement, options = {}) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
return render(ui, { wrapper: Wrapper, ...options });
}
// Component tests
describe('SearchResults', () => {
beforeEach(() => {
// Mock localStorage
Object.defineProperty(window, 'localStorage', {
value: {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
},
});
});
test('renders search input', () => {
renderWithProviders(<SearchResults />);
expect(screen.getByPlaceholderText('Search...')).toBeInTheDocument();
});
test('debounces search input', async () => {
const user = userEvent.setup();
renderWithProviders(<SearchResults />);
const input = screen.getByPlaceholderText('Search...');
// Type quickly
await user.type(input, 'react');
// Should not trigger API call immediately
expect(fetch).not.toHaveBeenCalled();
// Wait for debounce
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith('/api/search?q=react');
}, { timeout: 500 });
});
test('displays loading state', async () => {
// Mock delayed response
global.fetch = jest.fn(() =>
new Promise(resolve => setTimeout(resolve, 100))
);
renderWithProviders(<SearchResults />);
const input = screen.getByPlaceholderText('Search...');
await userEvent.type(input, 'test');
await waitFor(() => {
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
});
test('handles error states', async () => {
global.fetch = jest.fn(() => Promise.reject(new Error('API Error')));
renderWithProviders(<SearchResults />);
const input = screen.getByPlaceholderText('Search...');
await userEvent.type(input, 'test');
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
// Integration tests
describe('Modal Integration', () => {
test('modal workflow', async () => {
const user = userEvent.setup();
renderWithProviders(
<div>
<button>Open Modal</button>
<Modal isOpen={false} onClose={jest.fn()}>
<Modal.Header>Test Modal</Modal.Header>
<Modal.Body>Modal content</Modal.Body>
<Modal.Footer>
<button>Cancel</button>
<button>Confirm</button>
</Modal.Footer>
</Modal>
</div>
);
// Initially closed
expect(screen.queryByText('Test Modal')).not.toBeInTheDocument();
// Open modal
await user.click(screen.getByText('Open Modal'));
expect(screen.getByText('Test Modal')).toBeInTheDocument();
// Close with cancel
await user.click(screen.getByText('Cancel'));
expect(screen.queryByText('Test Modal')).not.toBeInTheDocument();
});
});Performance Optimization
Optimize your component architecture for better performance.
// Memoization patterns
const ExpensiveComponent = memo(function ExpensiveComponent({
data,
onUpdate
}: {
data: ComplexData[];
onUpdate: (id: string) => void;
}) {
const processedData = useMemo(() => {
return data.map(item => ({
...item,
computed: expensiveCalculation(item.value)
}));
}, [data]);
const stableOnUpdate = useCallback((id: string) => {
onUpdate(id);
}, [onUpdate]);
return (
<div>
{processedData.map(item => (
<ItemComponent
key={item.id}
item={item}
onUpdate={stableOnUpdate}
/>
))}
</div>
);
});
// Virtual scrolling for large lists
function VirtualizedList({ items, height = 400, itemHeight = 50 }) {
const [scrollTop, setScrollTop] = useState(0);
const containerHeight = height;
const totalHeight = items.length * itemHeight;
const visibleStart = Math.floor(scrollTop / itemHeight);
const visibleEnd = Math.min(
visibleStart + Math.ceil(containerHeight / itemHeight),
items.length - 1
);
const visibleItems = items.slice(visibleStart, visibleEnd + 1);
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
<div style={{ height: totalHeight, position: 'relative' }}>
{visibleItems.map((item, index) => (
<div
key={item.id}
style={{
position: 'absolute',
top: (visibleStart + index) * itemHeight,
height: itemHeight,
width: '100%'
}}
>
<ListItem item={item} />
</div>
))}
</div>
</div>
);
}
// Code splitting with lazy loading
const LazyDashboard = lazy(() => import('./Dashboard'));
const LazySettings = lazy(() => import('./Settings'));
function App() {
return (
<Router>
<Suspense fallback={<PageLoadingSpinner />}>
<Routes>
<Route path="/dashboard" element={<LazyDashboard />} />
<Route path="/settings" element={<LazySettings />} />
</Routes>
</Suspense>
</Router>
);
}Documentation and Style Guides
Document your component architecture for team consistency.
/**
* Component Architecture Guidelines
*
* 1. File Organization
* ├── components/
* │ ├── ui/ # Basic UI components (Button, Input, etc.)
* │ ├── layout/ # Layout components (Header, Sidebar, etc.)
* │ ├── features/ # Feature-specific components
* │ └── pages/ # Page-level components
*
* 2. Naming Conventions
* - PascalCase for component names
* - camelCase for props and handlers
* - UPPER_CASE for constants
*
* 3. Props Interface
* - Always define TypeScript interfaces for props
* - Use meaningful prop names
* - Provide default values where appropriate
*
* 4. Component Structure
* - Imports first
* - Types/Interfaces
* - Component implementation
* - Default export
*/
// Example component template
interface ComponentProps {
/** Main content of the component */
children: React.ReactNode;
/** Additional CSS classes */
className?: string;
/** Whether the component is disabled */
disabled?: boolean;
/** Click event handler */
onClick?: () => void;
}
export function Component({
children,
className = "",
disabled = false,
onClick
}: ComponentProps) {
// Hooks first
const [state, setState] = useState(false);
// Event handlers
const handleClick = useCallback(() => {
if (!disabled && onClick) {
onClick();
}
}, [disabled, onClick]);
// Render
return (
<div
className={`component-base ${className}`}
onClick={handleClick}
>
{children}
</div>
);
}Conclusion
Mastering component architecture is essential for building maintainable React applications. The patterns and techniques covered here provide a solid foundation for creating scalable, reusable, and testable components.
Key takeaways:
- Start with clear architectural principles
- Use composition over inheritance
- Embrace patterns like compound components and render props
- Optimize for performance without sacrificing readability
- Test your components thoroughly
- Document your architecture decisions
Great component architecture doesn't happen by accident—it's the result of thoughtful design, consistent patterns, and continuous refinement.
Ready to architect your next React application? Start with these patterns and adapt them to your specific needs. Remember: good architecture scales with your team and project complexity.