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

Mastering Component Architecture - Building Scalable React Applications

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.

component-hierarchy.tsx
// 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.

compound-components.tsx
// 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.

render-patterns.tsx
// 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.

higher-order-components.tsx
// 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.

custom-hooks.tsx
// 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.

component-testing.tsx
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.

performance-optimization.tsx
// 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-guidelines.ts
/**
 * 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.


Explore Family

Family is a beautiful self-custody Ethereum wallet designed to make crypto easy for everyone.

Get Started