React + TypeScript: Patterns for Scalable Frontend Architecture

SHEMANTI PALSHEMANTI PAL
10 min read

When building React applications that need to scale, the combination of TypeScript and thoughtful architectural patterns can make the difference between a codebase that's a pleasure to work with and one that becomes a maintenance nightmare. After working on numerous large-scale React applications, I've collected these patterns that have consistently helped teams build robust, scalable frontends.

The Foundation: Project Structure Matters

Before diving into specific patterns, let's address the elephant in the room: project structure. A well-organized project structure provides a blueprint for where code should live and how different pieces should interact.

Here's a structure that has scaled well for me:

src/
├── assets/               # Static assets like images, fonts
├── components/           # Shared UI components
│   ├── common/           # Very generic components (Button, Input, etc.)
│   ├── layout/           # Layout components (Header, Footer, etc.)
│   └── feature/          # More specific feature components
├── hooks/                # Custom React hooks
├── features/             # Feature-based modules
│   ├── authentication/   # Everything related to auth
│   │   ├── api/          # API integration
│   │   ├── components/   # Feature-specific components
│   │   ├── hooks/        # Feature-specific hooks
│   │   ├── types/        # TypeScript interfaces and types
│   │   ├── utils/        # Helper functions
│   │   └── index.ts      # Public API of the feature
│   └── ...               # Other features
├── api/                  # API client setup, interceptors
├── utils/                # Shared utility functions
├── types/                # Global TypeScript definitions
├── constants/            # App-wide constants
├── store/                # State management
└── App.tsx               # Main app component

The key insight here is organizing by feature rather than by technical role. This keeps related code together, making it easier to understand, maintain, and scale each feature independently.

TypeScript Patterns for Component Props

Let's start with the building blocks: components and their props.

Pattern 1: Prop Typing with Discriminated Unions

When a component can be in different modes or states, discriminated unions provide type safety:

type SuccessProps = {
  status: 'success';
  data: User[];
  onItemClick: (user: User) => void;
};

type LoadingProps = {
  status: 'loading';
};

type ErrorProps = {
  status: 'error';
  error: Error;
  onRetry: () => void;
};

type UserListProps = SuccessProps | LoadingProps | ErrorProps;

const UserList = (props: UserListProps) => {
  switch (props.status) {
    case 'loading':
      return <Spinner />;
    case 'error':
      return (
        <ErrorMessage 
          message={props.error.message} 
          onRetry={props.onRetry} 
        />
      );
    case 'success':
      return (
        <ul>
          {props.data.map(user => (
            <li 
              key={user.id} 
              onClick={() => props.onItemClick(user)}
            >
              {user.name}
            </li>
          ))}
        </ul>
      );
  }
};

This pattern ensures that you handle all possible states and have access to only the appropriate properties for each state.

Pattern 2: Component Composition with Children Props

Favor composition over configuration when building reusable components:

type CardProps = {
  className?: string;
  children: React.ReactNode;
};

type CardHeaderProps = {
  title: string;
  children?: React.ReactNode;
};

const Card = ({ className, children }: CardProps) => (
  <div className={`card ${className || ''}`}>{children}</div>
);

const CardHeader = ({ title, children }: CardHeaderProps) => (
  <div className="card-header">
    <h3>{title}</h3>
    {children}
  </div>
);

// Usage
const UserCard = ({ user }: { user: User }) => (
  <Card>
    <CardHeader title={user.name}>
      <span className="user-status">{user.status}</span>
    </CardHeader>
    <div className="card-body">{user.bio}</div>
  </Card>
);

This method creates flexible, composable components that can adapt to different use cases.

State Management Patterns

As applications grow, state management becomes the most challenging aspect. Here are some patterns that scale good:

Pattern 3: Context + Reducers for Feature State

For feature-specific state that needs to be accessed across multiple components:

// /features/authentication/types/index.ts
export type User = {
  id: string;
  name: string;
  email: string;
};

export type AuthState =
  | { status: 'idle' }
  | { status: 'loading' }
  | { status: 'authenticated'; user: User }
  | { status: 'error'; error: string };

export type AuthAction =
  | { type: 'LOGIN_REQUEST' }
  | { type: 'LOGIN_SUCCESS'; user: User }
  | { type: 'LOGIN_FAILURE'; error: string }
  | { type: 'LOGOUT' };

// /features/authentication/context/AuthContext.tsx
import { createContext, useReducer, useContext } from 'react';
import { AuthState, AuthAction, User } from '../types';

const initialState: AuthState = { status: 'idle' };

function authReducer(state: AuthState, action: AuthAction): AuthState {
  switch (action.type) {
    case 'LOGIN_REQUEST':
      return { status: 'loading' };
    case 'LOGIN_SUCCESS':
      return { status: 'authenticated', user: action.user };
    case 'LOGIN_FAILURE':
      return { status: 'error', error: action.error };
    case 'LOGOUT':
      return { status: 'idle' };
    default:
      return state;
  }
}

type AuthContextType = {
  state: AuthState;
  dispatch: React.Dispatch<AuthAction>;
  login: (email: string, password: string) => Promise<void>;
  logout: () => void;
};

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
  const [state, dispatch] = useReducer(authReducer, initialState);

  const login = async (email: string, password: string) => {
    dispatch({ type: 'LOGIN_REQUEST' });
    try {
      // API call here
      const user = await api.login(email, password);
      dispatch({ type: 'LOGIN_SUCCESS', user });
    } catch (error) {
      dispatch({ 
        type: 'LOGIN_FAILURE', 
        error: error instanceof Error ? error.message : 'Unknown error' 
      });
    }
  };

  const logout = () => {
    // API call or local cleanup
    dispatch({ type: 'LOGOUT' });
  };

  return (
    <AuthContext.Provider value={{ state, dispatch, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

export const useAuth = () => {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
};

This pattern encapsulates both the state and the operations on that state within a feature module, making it self-contained and reusable.

Pattern 4: Custom Hooks for Data Fetching

Abstract data fetching logic into custom hooks:

// /hooks/useFetch.ts
import { useState, useEffect } from 'react';

interface FetchState<T> {
  data: T | null;
  isLoading: boolean;
  error: Error | null;
}

export function useFetch<T>(url: string, options?: RequestInit) {
  const [state, setState] = useState<FetchState<T>>({
    data: null,
    isLoading: true,
    error: null,
  });

  useEffect(() => {
    let isMounted = true;

    const fetchData = async () => {
      setState(prev => ({ ...prev, isLoading: true }));

      try {
        const response = await fetch(url, options);

        if (!response.ok) {
          throw new Error(`HTTP error! Status: ${response.status}`);
        }

        const data = await response.json();

        if (isMounted) {
          setState({ data, isLoading: false, error: null });
        }
      } catch (error) {
        if (isMounted) {
          setState({ 
            data: null, 
            isLoading: false, 
            error: error instanceof Error ? error : new Error('Unknown error'),
          });
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false;
    };
  }, [url, JSON.stringify(options)]);

  return state;
}

// Usage in a component
const UserProfile = ({ userId }: { userId: string }) => {
  const { data: user, isLoading, error } = useFetch<User>(`/api/users/${userId}`);

  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage message={error.message} />;
  if (!user) return <p>No user found</p>;

  return <UserDetails user={user} />;
};

This separates data-fetching concerns from UI components, making both easier to test and maintain.

Reusability Patterns

As your application grows, you'll want to avoid duplicating code across features.

Pattern 5: Higher-Order Components with TypeScript

HOCs can add functionality to components in a type-safe way:

// /components/withErrorBoundary.tsx
import React, { Component, ComponentType } from 'react';

interface ErrorBoundaryProps {
  fallback: React.ReactNode;
}

interface ErrorBoundaryState {
  hasError: boolean;
  error: Error | null;
}

export function withErrorBoundary<P>(
  WrappedComponent: ComponentType<P>,
  fallback: React.ReactNode
) {
  return class WithErrorBoundary extends Component<P, ErrorBoundaryState> {
    state: ErrorBoundaryState = {
      hasError: false,
      error: null,
    };

    static getDerivedStateFromError(error: Error) {
      return { hasError: true, error };
    }

    componentDidCatch(error: Error, info: React.ErrorInfo) {
      console.error('Component error:', error, info);
      // You could send this to an error reporting service
    }

    render() {
      if (this.state.hasError) {
        return fallback;
      }

      return <WrappedComponent {...this.props} />;
    }
  };
}

// Usage
const UserProfileWithErrorBoundary = withErrorBoundary(
  UserProfile,
  <ErrorFallback message="Failed to load user profile" />
);

Pattern 6: Generic Component Types

Create reusable component types for common patterns:

// /types/components.ts
export type AsyncStateComponentProps<T> = {
  isLoading: boolean;
  error: Error | null;
  data: T | null;
  onRetry?: () => void;
};

// Usage
const UserList = ({ 
  isLoading, 
  error, 
  data, 
  onRetry 
}: AsyncStateComponentProps<User[]>) => {
  if (isLoading) return <Spinner />;
  if (error) return <ErrorMessage message={error.message} onRetry={onRetry} />;
  if (!data || data.length === 0) return <EmptyState message="No users found" />;

  return (
    <ul>
      {data.map(user => <UserListItem key={user.id} user={user} />)}
    </ul>
  );
};

API Integration Patterns

How you integrate with backend services can significantly impact maintainability.

Pattern 7: API Service Modules

Create dedicated modules for API interactions:

// /features/users/api/usersApi.ts
import { User, CreateUserDto, UpdateUserDto } from '../types';

export const usersApi = {
  getAll: async (): Promise<User[]> => {
    const response = await fetch('/api/users');
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    return response.json();
  },

  getById: async (id: string): Promise<User> => {
    const response = await fetch(`/api/users/${id}`);
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    return response.json();
  },

  create: async (user: CreateUserDto): Promise<User> => {
    const response = await fetch('/api/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(user),
    });
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    return response.json();
  },

  update: async (id: string, updates: UpdateUserDto): Promise<User> => {
    const response = await fetch(`/api/users/${id}`, {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(updates),
    });
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    return response.json();
  },

  delete: async (id: string): Promise<void> => {
    const response = await fetch(`/api/users/${id}`, {
      method: 'DELETE',
    });
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
  },
};

Then combine this with custom hooks for a clean component API:

// /features/users/hooks/useUsers.ts
import { useState, useCallback } from 'react';
import { usersApi } from '../api/usersApi';
import { User, CreateUserDto, UpdateUserDto } from '../types';

export function useUsers() {
  const [users, setUsers] = useState<User[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const fetchUsers = useCallback(async () => {
    setIsLoading(true);
    setError(null);

    try {
      const data = await usersApi.getAll();
      setUsers(data);
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Unknown error'));
    } finally {
      setIsLoading(false);
    }
  }, []);

  const createUser = useCallback(async (userData: CreateUserDto) => {
    setIsLoading(true);
    setError(null);

    try {
      const newUser = await usersApi.create(userData);
      setUsers(prev => [...prev, newUser]);
      return newUser;
    } catch (err) {
      setError(err instanceof Error ? err : new Error('Unknown error'));
      throw err;
    } finally {
      setIsLoading(false);
    }
  }, []);

  // Similar methods for update and delete

  return {
    users,
    isLoading,
    error,
    fetchUsers,
    createUser,
    // Additional methods
  };
}

Performance Optimization Patterns

As your application grows, performance becomes increasingly important.

Pattern 8: Memoization with TypeScript

Use TypeScript to ensure correct memoization:

import React, { useMemo } from 'react';

interface ExpensiveComponentProps {
  data: number[];
  threshold: number;
}

const ExpensiveComponent = ({ data, threshold }: ExpensiveComponentProps) => {
  // This calculation will only run when data or threshold changes
  const processedData = useMemo(() => {
    console.log('Processing data...');
    return data.filter(item => item > threshold)
               .map(item => item * 2)
               .sort((a, b) => a - b);
  }, [data, threshold]);

  return (
    <ul>
      {processedData.map((item, index) => (
        <li key={index}>{item}</li>
      ))}
    </ul>
  );
};

export default React.memo(ExpensiveComponent);

Pattern 9: Code-Splitting with React.lazy and TypeScript

Split your application into smaller chunks to improve initial load time:

import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import LoadingFallback from './components/common/LoadingFallback';

// Instead of:
// import Dashboard from './features/dashboard/components/Dashboard';
// Use:
const Dashboard = lazy(() => import('./features/dashboard/components/Dashboard'));
const UserProfile = lazy(() => import('./features/users/components/UserProfile'));
const Settings = lazy(() => import('./features/settings/components/Settings'));

const App = () => (
  <Router>
    <Suspense fallback={<LoadingFallback />}>
      <Routes>
        <Route path="/" element={<Dashboard />} />
        <Route path="/users/:id" element={<UserProfile />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  </Router>
);

TypeScript ensures that the lazily loaded components implement the correct interface.

Testing Patterns

Scalable applications need robust testing strategies.

Pattern 10: Component Testing with TypeScript

Use TypeScript to ensure your tests are robust:

import { render, screen, fireEvent } from '@testing-library/react';
import { UserList } from './UserList';
import { User } from '../types';

const mockUsers: User[] = [
  { id: '1', name: 'Alice', email: 'alice@example.com' },
  { id: '2', name: 'Bob', email: 'bob@example.com' },
];

describe('UserList', () => {
  test('renders loading state correctly', () => {
    render(<UserList isLoading={true} error={null} data={null} />);
    expect(screen.getByTestId('spinner')).toBeInTheDocument();
  });

  test('renders error state correctly', () => {
    const onRetry = jest.fn();
    const errorMessage = 'Failed to load users';

    render(
      <UserList 
        isLoading={false} 
        error={new Error(errorMessage)} 
        data={null}
        onRetry={onRetry}
      />
    );

    expect(screen.getByText(errorMessage)).toBeInTheDocument();

    fireEvent.click(screen.getByText('Retry'));
    expect(onRetry).toHaveBeenCalledTimes(1);
  });

  test('renders users correctly', () => {
    render(
      <UserList 
        isLoading={false} 
        error={null} 
        data={mockUsers}
      />
    );

    expect(screen.getByText('Alice')).toBeInTheDocument();
    expect(screen.getByText('Bob')).toBeInTheDocument();
  });
});

Conclusion: Evolving Architecture

The most important insight about scalable frontend architecture is that it's never "done." As your application grows, your architecture needs to evolve with it. The patterns shared here provide a foundation, but you should regularly revisit your architectural decisions.

Here are some signals that might indicate it's time to refactor:

  1. Similar code appearing in multiple features

  2. Components getting too large (over 300 lines)

  3. Too many props being passed down through multiple component levels

  4. Difficulty understanding the flow of data

  5. Features becoming tightly coupled

Remember, the goal of architecture is to manage complexity. When done right, adding new features becomes easier over time, not harder.

By leveraging TypeScript's type system with React's component model, you can build frontends that scale gracefully with your team and product requirements.

What patterns have you found helpful for scaling React applications? Share your experiences in the comments below!

0
Subscribe to my newsletter

Read articles from SHEMANTI PAL directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

SHEMANTI PAL
SHEMANTI PAL