React + TypeScript: Patterns for Scalable Frontend Architecture
Back to all articles
TypeScript
React

React + TypeScript: Patterns for Scalable Frontend Architecture

SHEMANTI PAL
SHEMANTI PAL
Jun 9, 2025
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:

plaintext
1src/ 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:

typescript
1type 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:

typescript
1type 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:

typescript
1// /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:

typescript
1// /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:

typescript
1// /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:

typescript
1// /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:

typescript
1// /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:

typescript
1// /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:

typescript
1import 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:

typescript
1import 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:

typescript
1import { 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:

  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!

Related Articles

Categories

Docker
containerization
container orchestration
TypeScript
React
LinkedIn
jobs
Scraping
hooks
Docker optimization
How to optimize Docker images for Next.js applications
Best practices for Docker image optimization in Next.js
Improving Next.js performance with Docker Reducing Docker image size for Next.js apps
Multi-stage builds for Next.js Docker images
Next.js performance
docker images
Web Development
GitHub
Git
merge
git rebase
git merge --squash
prepverse
Data Science
dataanalytics
data analysis
ReduxVsZustand
zustand
Zustand tutorial
State Management
Redux
redux-toolkit
technology
version control
github-actions
Zustand store
repository
2025 technology trends
opensource
Developer
portfolio
preparation
interview
engineering
Interview tips
#ai-tools
Technical Skills
remote jobs
Technical interview
JavaScript
Open Source
software development