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:
plaintext1src/ 2โโโ assets/ # Static assets like images, fonts 3โโโ components/ # Shared UI components 4โ โโโ common/ # Very generic components (Button, Input, etc.) 5โ โโโ layout/ # Layout components (Header, Footer, etc.) 6โ โโโ feature/ # More specific feature components 7โโโ hooks/ # Custom React hooks 8โโโ features/ # Feature-based modules 9โ โโโ authentication/ # Everything related to auth 10โ โ โโโ api/ # API integration 11โ โ โโโ components/ # Feature-specific components 12โ โ โโโ hooks/ # Feature-specific hooks 13โ โ โโโ types/ # TypeScript interfaces and types 14โ โ โโโ utils/ # Helper functions 15โ โ โโโ index.ts # Public API of the feature 16โ โโโ ... # Other features 17โโโ api/ # API client setup, interceptors 18โโโ utils/ # Shared utility functions 19โโโ types/ # Global TypeScript definitions 20โโโ constants/ # App-wide constants 21โโโ store/ # State management 22โโโ 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:
typescript1type SuccessProps = { 2 status: 'success'; 3 data: User[]; 4 onItemClick: (user: User) => void; 5}; 6 7type LoadingProps = { 8 status: 'loading'; 9}; 10 11type ErrorProps = { 12 status: 'error'; 13 error: Error; 14 onRetry: () => void; 15}; 16 17type UserListProps = SuccessProps | LoadingProps | ErrorProps; 18 19const UserList = (props: UserListProps) => { 20 switch (props.status) { 21 case 'loading': 22 return <Spinner />; 23 case 'error': 24 return ( 25 <ErrorMessage 26 message={props.error.message} 27 onRetry={props.onRetry} 28 /> 29 ); 30 case 'success': 31 return ( 32 <ul> 33 {props.data.map(user => ( 34 <li 35 key={user.id} 36 onClick={() => props.onItemClick(user)} 37 > 38 {user.name} 39 </li> 40 ))} 41 </ul> 42 ); 43 } 44};
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:
typescript1type CardProps = { 2 className?: string; 3 children: React.ReactNode; 4}; 5 6type CardHeaderProps = { 7 title: string; 8 children?: React.ReactNode; 9}; 10 11const Card = ({ className, children }: CardProps) => ( 12 <div className={`card ${className || ''}`}>{children}</div> 13); 14 15const CardHeader = ({ title, children }: CardHeaderProps) => ( 16 <div className="card-header"> 17 <h3>{title}</h3> 18 {children} 19 </div> 20); 21 22// Usage 23const UserCard = ({ user }: { user: User }) => ( 24 <Card> 25 <CardHeader title={user.name}> 26 <span className="user-status">{user.status}</span> 27 </CardHeader> 28 <div className="card-body">{user.bio}</div> 29 </Card> 30);
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:
typescript1// /features/authentication/types/index.ts 2export type User = { 3 id: string; 4 name: string; 5 email: string; 6}; 7 8export type AuthState = 9 | { status: 'idle' } 10 | { status: 'loading' } 11 | { status: 'authenticated'; user: User } 12 | { status: 'error'; error: string }; 13 14export type AuthAction = 15 | { type: 'LOGIN_REQUEST' } 16 | { type: 'LOGIN_SUCCESS'; user: User } 17 | { type: 'LOGIN_FAILURE'; error: string } 18 | { type: 'LOGOUT' }; 19 20// /features/authentication/context/AuthContext.tsx 21import { createContext, useReducer, useContext } from 'react'; 22import { AuthState, AuthAction, User } from '../types'; 23 24const initialState: AuthState = { status: 'idle' }; 25 26function authReducer(state: AuthState, action: AuthAction): AuthState { 27 switch (action.type) { 28 case 'LOGIN_REQUEST': 29 return { status: 'loading' }; 30 case 'LOGIN_SUCCESS': 31 return { status: 'authenticated', user: action.user }; 32 case 'LOGIN_FAILURE': 33 return { status: 'error', error: action.error }; 34 case 'LOGOUT': 35 return { status: 'idle' }; 36 default: 37 return state; 38 } 39} 40 41type AuthContextType = { 42 state: AuthState; 43 dispatch: React.Dispatch<AuthAction>; 44 login: (email: string, password: string) => Promise<void>; 45 logout: () => void; 46}; 47 48const AuthContext = createContext<AuthContextType | undefined>(undefined); 49 50export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 51 const [state, dispatch] = useReducer(authReducer, initialState); 52 53 const login = async (email: string, password: string) => { 54 dispatch({ type: 'LOGIN_REQUEST' }); 55 try { 56 // API call here 57 const user = await api.login(email, password); 58 dispatch({ type: 'LOGIN_SUCCESS', user }); 59 } catch (error) { 60 dispatch({ 61 type: 'LOGIN_FAILURE', 62 error: error instanceof Error ? error.message : 'Unknown error' 63 }); 64 } 65 }; 66 67 const logout = () => { 68 // API call or local cleanup 69 dispatch({ type: 'LOGOUT' }); 70 }; 71 72 return ( 73 <AuthContext.Provider value={{ state, dispatch, login, logout }}> 74 {children} 75 </AuthContext.Provider> 76 ); 77}; 78 79export const useAuth = () => { 80 const context = useContext(AuthContext); 81 if (context === undefined) { 82 throw new Error('useAuth must be used within an AuthProvider'); 83 } 84 return context; 85};
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:
typescript1// /hooks/useFetch.ts 2import { useState, useEffect } from 'react'; 3 4interface FetchState<T> { 5 data: T | null; 6 isLoading: boolean; 7 error: Error | null; 8} 9 10export function useFetch<T>(url: string, options?: RequestInit) { 11 const [state, setState] = useState<FetchState<T>>({ 12 data: null, 13 isLoading: true, 14 error: null, 15 }); 16 17 useEffect(() => { 18 let isMounted = true; 19 20 const fetchData = async () => { 21 setState(prev => ({ ...prev, isLoading: true })); 22 23 try { 24 const response = await fetch(url, options); 25 26 if (!response.ok) { 27 throw new Error(`HTTP error! Status: ${response.status}`); 28 } 29 30 const data = await response.json(); 31 32 if (isMounted) { 33 setState({ data, isLoading: false, error: null }); 34 } 35 } catch (error) { 36 if (isMounted) { 37 setState({ 38 data: null, 39 isLoading: false, 40 error: error instanceof Error ? error : new Error('Unknown error'), 41 }); 42 } 43 } 44 }; 45 46 fetchData(); 47 48 return () => { 49 isMounted = false; 50 }; 51 }, [url, JSON.stringify(options)]); 52 53 return state; 54} 55 56// Usage in a component 57const UserProfile = ({ userId }: { userId: string }) => { 58 const { data: user, isLoading, error } = useFetch<User>(`/api/users/${userId}`); 59 60 if (isLoading) return <Spinner />; 61 if (error) return <ErrorMessage message={error.message} />; 62 if (!user) return <p>No user found</p>; 63 64 return <UserDetails user={user} />; 65};
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:
typescript1// /components/withErrorBoundary.tsx 2import React, { Component, ComponentType } from 'react'; 3 4interface ErrorBoundaryProps { 5 fallback: React.ReactNode; 6} 7 8interface ErrorBoundaryState { 9 hasError: boolean; 10 error: Error | null; 11} 12 13export function withErrorBoundary<P>( 14 WrappedComponent: ComponentType<P>, 15 fallback: React.ReactNode 16) { 17 return class WithErrorBoundary extends Component<P, ErrorBoundaryState> { 18 state: ErrorBoundaryState = { 19 hasError: false, 20 error: null, 21 }; 22 23 static getDerivedStateFromError(error: Error) { 24 return { hasError: true, error }; 25 } 26 27 componentDidCatch(error: Error, info: React.ErrorInfo) { 28 console.error('Component error:', error, info); 29 // You could send this to an error reporting service 30 } 31 32 render() { 33 if (this.state.hasError) { 34 return fallback; 35 } 36 37 return <WrappedComponent {...this.props} />; 38 } 39 }; 40} 41 42// Usage 43const UserProfileWithErrorBoundary = withErrorBoundary( 44 UserProfile, 45 <ErrorFallback message="Failed to load user profile" /> 46);
Pattern 6: Generic Component Types
Create reusable component types for common patterns:
typescript1// /types/components.ts 2export type AsyncStateComponentProps<T> = { 3 isLoading: boolean; 4 error: Error | null; 5 data: T | null; 6 onRetry?: () => void; 7}; 8 9// Usage 10const UserList = ({ 11 isLoading, 12 error, 13 data, 14 onRetry 15}: AsyncStateComponentProps<User[]>) => { 16 if (isLoading) return <Spinner />; 17 if (error) return <ErrorMessage message={error.message} onRetry={onRetry} />; 18 if (!data || data.length === 0) return <EmptyState message="No users found" />; 19 20 return ( 21 <ul> 22 {data.map(user => <UserListItem key={user.id} user={user} />)} 23 </ul> 24 ); 25};
API Integration Patterns
How you integrate with backend services can significantly impact maintainability.
Pattern 7: API Service Modules
Create dedicated modules for API interactions:
typescript1// /features/users/api/usersApi.ts 2import { User, CreateUserDto, UpdateUserDto } from '../types'; 3 4export const usersApi = { 5 getAll: async (): Promise<User[]> => { 6 const response = await fetch('/api/users'); 7 if (!response.ok) { 8 throw new Error(`HTTP error! Status: ${response.status}`); 9 } 10 return response.json(); 11 }, 12 13 getById: async (id: string): Promise<User> => { 14 const response = await fetch(`/api/users/${id}`); 15 if (!response.ok) { 16 throw new Error(`HTTP error! Status: ${response.status}`); 17 } 18 return response.json(); 19 }, 20 21 create: async (user: CreateUserDto): Promise<User> => { 22 const response = await fetch('/api/users', { 23 method: 'POST', 24 headers: { 'Content-Type': 'application/json' }, 25 body: JSON.stringify(user), 26 }); 27 if (!response.ok) { 28 throw new Error(`HTTP error! Status: ${response.status}`); 29 } 30 return response.json(); 31 }, 32 33 update: async (id: string, updates: UpdateUserDto): Promise<User> => { 34 const response = await fetch(`/api/users/${id}`, { 35 method: 'PUT', 36 headers: { 'Content-Type': 'application/json' }, 37 body: JSON.stringify(updates), 38 }); 39 if (!response.ok) { 40 throw new Error(`HTTP error! Status: ${response.status}`); 41 } 42 return response.json(); 43 }, 44 45 delete: async (id: string): Promise<void> => { 46 const response = await fetch(`/api/users/${id}`, { 47 method: 'DELETE', 48 }); 49 if (!response.ok) { 50 throw new Error(`HTTP error! Status: ${response.status}`); 51 } 52 }, 53};
Then combine this with custom hooks for a clean component API:
typescript1// /features/users/hooks/useUsers.ts 2import { useState, useCallback } from 'react'; 3import { usersApi } from '../api/usersApi'; 4import { User, CreateUserDto, UpdateUserDto } from '../types'; 5 6export function useUsers() { 7 const [users, setUsers] = useState<User[]>([]); 8 const [isLoading, setIsLoading] = useState(false); 9 const [error, setError] = useState<Error | null>(null); 10 11 const fetchUsers = useCallback(async () => { 12 setIsLoading(true); 13 setError(null); 14 15 try { 16 const data = await usersApi.getAll(); 17 setUsers(data); 18 } catch (err) { 19 setError(err instanceof Error ? err : new Error('Unknown error')); 20 } finally { 21 setIsLoading(false); 22 } 23 }, []); 24 25 const createUser = useCallback(async (userData: CreateUserDto) => { 26 setIsLoading(true); 27 setError(null); 28 29 try { 30 const newUser = await usersApi.create(userData); 31 setUsers(prev => [...prev, newUser]); 32 return newUser; 33 } catch (err) { 34 setError(err instanceof Error ? err : new Error('Unknown error')); 35 throw err; 36 } finally { 37 setIsLoading(false); 38 } 39 }, []); 40 41 // Similar methods for update and delete 42 43 return { 44 users, 45 isLoading, 46 error, 47 fetchUsers, 48 createUser, 49 // Additional methods 50 }; 51}
Performance Optimization Patterns
As your application grows, performance becomes increasingly important.
Pattern 8: Memoization with TypeScript
Use TypeScript to ensure correct memoization:
typescript1import React, { useMemo } from 'react'; 2 3interface ExpensiveComponentProps { 4 data: number[]; 5 threshold: number; 6} 7 8const ExpensiveComponent = ({ data, threshold }: ExpensiveComponentProps) => { 9 // This calculation will only run when data or threshold changes 10 const processedData = useMemo(() => { 11 console.log('Processing data...'); 12 return data.filter(item => item > threshold) 13 .map(item => item * 2) 14 .sort((a, b) => a - b); 15 }, [data, threshold]); 16 17 return ( 18 <ul> 19 {processedData.map((item, index) => ( 20 <li key={index}>{item}</li> 21 ))} 22 </ul> 23 ); 24}; 25 26export default React.memo(ExpensiveComponent);
Pattern 9: Code-Splitting with React.lazy and TypeScript
Split your application into smaller chunks to improve initial load time:
typescript1import React, { Suspense, lazy } from 'react'; 2import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; 3import LoadingFallback from './components/common/LoadingFallback'; 4 5// Instead of: 6// import Dashboard from './features/dashboard/components/Dashboard'; 7// Use: 8const Dashboard = lazy(() => import('./features/dashboard/components/Dashboard')); 9const UserProfile = lazy(() => import('./features/users/components/UserProfile')); 10const Settings = lazy(() => import('./features/settings/components/Settings')); 11 12const App = () => ( 13 <Router> 14 <Suspense fallback={<LoadingFallback />}> 15 <Routes> 16 <Route path="/" element={<Dashboard />} /> 17 <Route path="/users/:id" element={<UserProfile />} /> 18 <Route path="/settings" element={<Settings />} /> 19 </Routes> 20 </Suspense> 21 </Router> 22);
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:
typescript1import { render, screen, fireEvent } from '@testing-library/react'; 2import { UserList } from './UserList'; 3import { User } from '../types'; 4 5const mockUsers: User[] = [ 6 { id: '1', name: 'Alice', email: 'alice@example.com' }, 7 { id: '2', name: 'Bob', email: 'bob@example.com' }, 8]; 9 10describe('UserList', () => { 11 test('renders loading state correctly', () => { 12 render(<UserList isLoading={true} error={null} data={null} />); 13 expect(screen.getByTestId('spinner')).toBeInTheDocument(); 14 }); 15 16 test('renders error state correctly', () => { 17 const onRetry = jest.fn(); 18 const errorMessage = 'Failed to load users'; 19 20 render( 21 <UserList 22 isLoading={false} 23 error={new Error(errorMessage)} 24 data={null} 25 onRetry={onRetry} 26 /> 27 ); 28 29 expect(screen.getByText(errorMessage)).toBeInTheDocument(); 30 31 fireEvent.click(screen.getByText('Retry')); 32 expect(onRetry).toHaveBeenCalledTimes(1); 33 }); 34 35 test('renders users correctly', () => { 36 render( 37 <UserList 38 isLoading={false} 39 error={null} 40 data={mockUsers} 41 /> 42 ); 43 44 expect(screen.getByText('Alice')).toBeInTheDocument(); 45 expect(screen.getByText('Bob')).toBeInTheDocument(); 46 }); 47});
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:
-
Similar code appearing in multiple features
-
Components getting too large (over 300 lines)
-
Too many props being passed down through multiple component levels
-
Difficulty understanding the flow of data
-
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!