Staff Engineer180 min read

Building Scalable React Applications: Architecture Patterns

Learn enterprise-level architecture patterns, code organization, and strategies for building maintainable large-scale applications.

Topics Covered:

Folder StructureModule BoundariesDependency ManagementTesting StrategyMonorepos

Prerequisites:

  • React Internals: How React Works Under the Hood

Overview

Staff engineers design systems, not just features. This tutorial teaches you patterns and strategies for building applications that scale to hundreds of developers and millions of users. We'll cover architecture decisions, folder structures, dependency management, testing strategies, and organizational patterns used by companies like Facebook, Google, and Netflix.

Lesson 1: Choosing the Right Architecture Pattern

The architecture pattern you choose impacts every aspect of your application. Let's explore different patterns and when to use them. Architecture Patterns: 1. Feature-Based (Recommended for Large Apps) • Organize by feature/domain • Each feature is self-contained • Scales well with team size • Used by: Facebook, Airbnb 2. Layer-Based (Good for Smaller Apps) • Organize by technical layers • Simple but can lead to coupling • Good for: Startups, MVPs 3. Domain-Driven Design (Enterprise) • Organize by business domains • Clear bounded contexts • Used by: Financial systems, Enterprise apps Decision Framework: • Team size: < 5 → Layer-based, > 10 → Feature-based • Application complexity: Simple → Layers, Complex → Features • Team structure: Monolith team → Layers, Feature teams → Features

Code Example:
// ❌ BAD: Type-based organization (doesn't scale)
src/
  components/
    Button.tsx
    Input.tsx
    Header.tsx
    Dashboard.tsx
    UserProfile.tsx
    LoginForm.tsx
  hooks/
    useAuth.ts
    useDashboard.ts
    useProfile.ts
  services/
    auth.ts
    dashboard.ts
    profile.ts
// Problem: Hard to find related code
// Problem: Circular dependencies
// Problem: Doesn't scale with team

// ✅ GOOD: Feature-based organization
src/
  features/
    authentication/
      components/
        LoginForm.tsx
        SignUpForm.tsx
      hooks/
        useAuth.ts
        useSession.ts
      services/
        authService.ts
        sessionService.ts
      types/
        auth.types.ts
      index.ts  // Public API
    dashboard/
      components/
        Dashboard.tsx
        DashboardCard.tsx
      hooks/
        useDashboard.ts
        useDashboardData.ts
      services/
        dashboardService.ts
      types/
        dashboard.types.ts
      index.ts
    user-profile/
      components/
        UserProfile.tsx
        ProfileEdit.tsx
      hooks/
        useProfile.ts
      services/
        profileService.ts
      types/
        profile.types.ts
      index.ts
  shared/
    components/
      Button.tsx
      Input.tsx
    hooks/
      useDebounce.ts
    utils/
      format.ts
      validation.ts
  app/
    routes.tsx
    App.tsx
    layout.tsx

Feature-based organization keeps related code together. Each feature exports a public API through index.ts. Shared code is truly shared. This scales to large teams where different teams own different features.

Lesson 2: Setting Up Module Boundaries and Dependency Rules

Clear boundaries prevent coupling and make the codebase maintainable. Let's establish dependency rules step by step. Step 1: Define Dependency Direction • Features cannot import from other features • Features can import from shared • Shared cannot import from features • App can import from features and shared Step 2: Use Barrel Exports (index.ts) Each feature should export a public API Step 3: Enforce with Tooling • Use ESLint rules • Use TypeScript path mapping • Document the rules clearly

Code Example:
// ✅ GOOD: Feature with clear boundaries

// features/authentication/index.ts (Public API)
export { LoginForm, SignUpForm } from './components';
export { useAuth, useSession } from './hooks';
export { authService } from './services';
export type { User, AuthState } from './types';

// ❌ BAD: Direct internal imports from outside
// features/dashboard/components/Dashboard.tsx
import { LoginForm } from '../authentication/components/LoginForm';
// Should use:
import { LoginForm } from '@/features/authentication';

// ✅ GOOD: Using public API
import { LoginForm } from '@/features/authentication';

// ✅ GOOD: Shared utilities
// shared/utils/format.ts
export function formatCurrency(amount: number): string {
  return `$${amount.toFixed(2)}`;
}

// Can be used by any feature
import { formatCurrency } from '@/shared/utils/format';

// ❌ BAD: Feature importing from another feature directly
// features/dashboard/services/dashboardService.ts
import { authService } from '../authentication/services/authService';
// Should use shared or re-export through public API

// ✅ GOOD: TypeScript path aliases enforce structure
// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/features/*": ["src/features/*"],
      "@/shared/*": ["src/shared/*"],
      "@/app/*": ["src/app/*"]
    }
  }
}

// ESLint rule to enforce boundaries
// .eslintrc.js
module.exports = {
  rules: {
    'no-restricted-imports': [
      'error',
      {
        patterns: [
          {
            group: ['features/*/services/*', 'features/*/hooks/*'],
            message: 'Use public API from features/*/index.ts instead'
          }
        ]
      }
    ]
  }
};

Clear boundaries prevent features from coupling. Use barrel exports to define public APIs. Enforce with tooling to prevent accidental violations.

Lesson 3: Dependency Injection and Service Layer

For large applications, use dependency injection to decouple features and make testing easier. Benefits: • Testable: Easy to mock dependencies • Flexible: Swap implementations • Decoupled: Features don't depend on concrete implementations Implementation Pattern: • Create service interfaces • Implement services • Inject through context or props • Use factories for complex creation

Code Example:
// Step 1: Define service interface
// shared/services/api/IApiService.ts
export interface IApiService {
  get<T>(url: string): Promise<T>;
  post<T>(url: string, data: any): Promise<T>;
}

// Step 2: Implement service
// shared/services/api/ApiService.ts
export class ApiService implements IApiService {
  constructor(private baseUrl: string) {}
  
  async get<T>(url: string): Promise<T> {
    const response = await fetch(`${this.baseUrl}${url}`);
    return response.json();
  }
  
  async post<T>(url: string, data: any): Promise<T> {
    const response = await fetch(`${this.baseUrl}${url}`, {
      method: 'POST',
      body: JSON.stringify(data),
    });
    return response.json();
  }
}

// Step 3: Create context for dependency injection
// shared/services/ServiceContext.tsx
import { createContext, useContext } from 'react';

interface Services {
  api: IApiService;
  // Add other services
}

const ServiceContext = createContext<Services | null>(null);

export function ServiceProvider({ 
  children, 
  services 
}: { 
  children: React.ReactNode;
  services: Services;
}) {
  return (
    <ServiceContext.Provider value={services}>
      {children}
    </ServiceContext.Provider>
  );
}

export function useServices() {
  const services = useContext(ServiceContext);
  if (!services) {
    throw new Error('useServices must be used within ServiceProvider');
  }
  return services;
}

// Step 4: Use in features
// features/dashboard/services/dashboardService.ts
import { useServices } from '@/shared/services/ServiceContext';

export function useDashboardService() {
  const { api } = useServices();
  
  return {
    getDashboardData: () => api.get('/dashboard'),
    updateDashboard: (data: any) => api.post('/dashboard', data),
  };
}

// Step 5: Setup in app
// app/App.tsx
const services: Services = {
  api: new ApiService(process.env.API_URL),
};

function App() {
  return (
    <ServiceProvider services={services}>
      {/* Your app */}
    </ServiceProvider>
  );
}

// Step 6: Testing with mocks
// __tests__/Dashboard.test.tsx
const mockServices: Services = {
  api: {
    get: jest.fn(),
    post: jest.fn(),
  },
};

test('loads dashboard data', () => {
  render(
    <ServiceProvider services={mockServices}>
      <Dashboard />
    </ServiceProvider>
  );
});

Dependency injection decouples features from concrete implementations. Services are injected through context, making components testable and flexible.

Lesson 4: Code Splitting Strategy at Scale

At scale, code splitting becomes crucial for performance. Let's design a comprehensive splitting strategy. Splitting Levels: 1. Route-based: Split by routes (largest chunks) 2. Feature-based: Split by feature 3. Component-based: Split heavy components 4. Library-based: Split vendor libraries Performance Targets: • Initial bundle: < 200KB (gzipped) • Route chunks: < 100KB each • Load time: < 3s on 3G Monitoring: • Use webpack-bundle-analyzer • Track bundle sizes in CI • Monitor real user metrics

Code Example:
// Step 1: Route-based splitting
// app/routes.tsx
import { lazy } from 'react';

// Split by route
const HomePage = lazy(() => import('@/features/home'));
const DashboardPage = lazy(() => import('@/features/dashboard'));
const ProfilePage = lazy(() => import('@/features/user-profile'));

// Step 2: Feature-based splitting
// features/dashboard/index.ts
// Re-export only public API, internal components stay lazy
export { Dashboard } from './components/Dashboard';
export { useDashboard } from './hooks/useDashboard';

// Heavy internal components are lazy loaded
const HeavyChart = lazy(() => import('./components/HeavyChart'));

// Step 3: Component-based splitting
// components/Editor.tsx
import { lazy, Suspense } from 'react';

const CodeEditor = lazy(() => import('./CodeEditor'));
const RichTextEditor = lazy(() => import('./RichTextEditor'));

function Editor({ type }) {
  return (
    <Suspense fallback={<EditorSkeleton />}>
      {type === 'code' ? <CodeEditor /> : <RichTextEditor />}
    </Suspense>
  );
}

// Step 4: Library-based splitting
// Split large vendor libraries
const MonacoEditor = lazy(() => 
  import('monaco-editor').then(module => ({ default: module.Editor }))
);

// Step 5: Bundle analysis
// webpack.config.js or next.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer')
  .BundleAnalyzerPlugin;

module.exports = {
  webpack: (config, { isServer }) => {
    if (!isServer && process.env.ANALYZE === 'true') {
      config.plugins.push(
        new BundleAnalyzerPlugin({
          analyzerMode: 'static',
          openAnalyzer: false,
        })
      );
    }
    return config;
  },
};

// Step 6: Preload critical routes
// Preload route when user hovers
<Link
  to="/dashboard"
  onMouseEnter={() => {
    import('@/features/dashboard');
  }}
>
  Dashboard
</Link>

Multi-level code splitting strategy: routes first, then features, then heavy components. Use bundle analysis to identify opportunities. Preload critical routes on interaction.

Lesson 5: Testing Strategy for Large Applications

Large applications need a comprehensive testing strategy. Different types of tests serve different purposes. Testing Pyramid: • Unit Tests (70%): Test individual functions/components • Integration Tests (20%): Test feature interactions • E2E Tests (10%): Test critical user flows Test Organization: • Mirror source structure • Co-locate tests with code • Use test utilities for common patterns CI/CD Integration: • Run unit tests on every commit • Run integration tests on PR • Run E2E tests on merge

Code Example:
// Step 1: Test structure mirrors source
src/
  features/
    dashboard/
      components/
        Dashboard.tsx
        Dashboard.test.tsx
      hooks/
        useDashboard.ts
        useDashboard.test.ts
      services/
        dashboardService.ts
        dashboardService.test.ts
      __tests__/  // Integration tests
        Dashboard.integration.test.tsx

// Step 2: Unit test example
// features/dashboard/hooks/useDashboard.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { useDashboard } from './useDashboard';
import { ServiceProvider } from '@/shared/services/ServiceContext';

const mockServices = {
  api: {
    get: jest.fn(),
  },
};

test('loads dashboard data', async () => {
  mockServices.api.get.mockResolvedValue({ data: [] });
  
  const { result } = renderHook(() => useDashboard(), {
    wrapper: ({ children }) => (
      <ServiceProvider services={mockServices}>
        {children}
      </ServiceProvider>
    ),
  });
  
  await waitFor(() => {
    expect(result.current.data).toBeDefined();
  });
});

// Step 3: Integration test example
// features/dashboard/__tests__/Dashboard.integration.test.tsx
import { render, screen } from '@testing-library/react';
import { Dashboard } from '../components/Dashboard';
import { ServiceProvider } from '@/shared/services/ServiceContext';

const mockServices = createMockServices();

test('dashboard loads and displays data', async () => {
  render(
    <ServiceProvider services={mockServices}>
      <Dashboard />
    </ServiceProvider>
  );
  
  expect(screen.getByText('Loading...')).toBeInTheDocument();
  
  await waitFor(() => {
    expect(screen.getByText('Dashboard')).toBeInTheDocument();
  });
});

// Step 4: Test utilities
// shared/test-utils/index.tsx
export function renderWithProviders(
  ui: React.ReactElement,
  options = {}
) {
  const defaultServices = createMockServices();
  
  function Wrapper({ children }) {
    return (
      <ServiceProvider services={defaultServices}>
        <ThemeProvider>
          {children}
        </ThemeProvider>
      </ServiceProvider>
    );
  }
  
  return render(ui, { wrapper: Wrapper, ...options });
}

// Use in tests
test('component renders', () => {
  renderWithProviders(<MyComponent />);
});

// Step 5: E2E test example (Playwright/Cypress)
// e2e/dashboard.spec.ts
test('user can view dashboard', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.locator('h1')).toContainText('Dashboard');
});

Comprehensive testing strategy: unit tests for components/hooks, integration tests for features, E2E tests for critical flows. Use test utilities to reduce boilerplate.

Lesson 6: State Management Architecture

For large applications, you need a clear state management strategy. State Management Layers: 1. Local State: Component-specific (useState) 2. Shared State: Feature-level (Context/Redux slice) 3. Global State: App-level (Redux/Zustand) 4. Server State: API data (React Query/SWR) Decision Tree: • Only one component? → useState • Shared within feature? → Context or feature store • Shared across features? → Global store • Server data? → React Query/SWR Best Practices: • Don't put server state in global store • Keep state close to where it's used • Normalize complex state

Code Example:
// Step 1: Local state (component-specific)
function Counter() {
  const [count, setCount] = useState(0);
  // Only used in this component
}

// Step 2: Feature-level state (Context)
// features/dashboard/DashboardContext.tsx
const DashboardContext = createContext<DashboardState | null>(null);

export function DashboardProvider({ children }) {
  const [state, setState] = useState<DashboardState>({
    widgets: [],
    layout: 'grid',
  });
  
  return (
    <DashboardContext.Provider value={{ state, setState }}>
      {children}
    </DashboardContext.Provider>
  );
}

// Step 3: Global state (Redux Toolkit)
// store/slices/dashboardSlice.ts
import { createSlice } from '@reduxjs/toolkit';

const dashboardSlice = createSlice({
  name: 'dashboard',
  initialState: { widgets: [] },
  reducers: {
    addWidget: (state, action) => {
      state.widgets.push(action.payload);
    },
  },
});

// Step 4: Server state (React Query)
// features/dashboard/hooks/useDashboardData.ts
import { useQuery } from '@tanstack/react-query';

export function useDashboardData() {
  return useQuery({
    queryKey: ['dashboard'],
    queryFn: () => dashboardService.getDashboard(),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}

// Step 5: Combining layers
function Dashboard() {
  // Local state
  const [isExpanded, setIsExpanded] = useState(false);
  
  // Feature state
  const { layout } = useDashboardContext();
  
  // Server state
  const { data, isLoading } = useDashboardData();
  
  // Global state
  const user = useSelector(state => state.user);
  
  // Each layer serves its purpose
}

Multi-layer state management: local for component, context for feature, global store for app-wide, React Query for server. Each layer has its purpose.

Lesson 7: Monorepo Strategy

Monorepos enable code sharing across multiple applications and services. When to Use Monorepos: • Multiple apps sharing code • Design system components • Shared utilities across projects • Micro-frontend architecture Tools: • Nx: Advanced build system • Turborepo: Fast build system • Yarn/NPM workspaces: Simple setup Structure: • apps/: Applications • packages/: Shared packages • tools/: Build tools Benefits: • Code sharing • Atomic changes • Consistent versions • Single CI/CD pipeline

Conclusion

Architecture is about trade-offs. Make decisions based on team size, application scale, and long-term maintainability. Start simple, add structure as you grow. Document your decisions and enforce them with tooling.