Learn how to architect large-scale React applications with best practices for state management, component organization, and performance optimization. This article covers advanced patterns and techniques for building maintainable React codebases.
Building scalable React applications requires careful planning, solid architecture decisions, and adherence to best practices. As applications grow in complexity, maintaining code quality, performance, and developer productivity becomes increasingly challenging.
This comprehensive guide covers the essential patterns, tools, and techniques needed to build React applications that can scale from small prototypes to enterprise-level solutions serving millions of users.
A well-organized folder structure is the foundation of a scalable React application:
src/
├── components/ # Reusable UI components
│ ├── common/ # Shared components
│ ├── forms/ # Form-specific components
│ └── layout/ # Layout components
├── pages/ # Page-level components
├── hooks/ # Custom React hooks
├── services/ # API calls and external services
├── utils/ # Pure utility functions
├── store/ # State management
├── types/ # TypeScript type definitions
├── constants/ # Application constants
├── assets/ # Static assets
└── tests/ # Test utilities and setup
For larger applications, consider organizing by features:
src/
├── features/
│ ├── authentication/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ ├── store/
│ │ └── types/
│ ├── dashboard/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ ├── store/
│ │ └── types/
│ └── user-management/
├── shared/ # Shared across features
├── app/ # App-level configuration
└── pages/ # Route-level components
Centralize configuration and environment variables:
// src/config/index.js
const config = {
API_BASE_URL: process.env.REACT_APP_API_BASE_URL || 'http://localhost:3001',
ENVIRONMENT: process.env.NODE_ENV,
FEATURES: {
ANALYTICS: process.env.REACT_APP_ENABLE_ANALYTICS === 'true',
DARK_MODE: process.env.REACT_APP_ENABLE_DARK_MODE === 'true',
},
TIMEOUTS: {
API_REQUEST: 10000,
DEBOUNCE: 300,
},
};
export default config;
Organize components into clear categories:
Pure, reusable UI components without business logic:
// src/components/common/Button/Button.jsx
import React from 'react';
import PropTypes from 'prop-types';
import './Button.css';
const Button = ({
children,
variant = 'primary',
size = 'medium',
disabled = false,
onClick,
...props
}) => {
const classNames = [
'button',
`button--${variant}`,
`button--${size}`,
disabled && 'button--disabled'
].filter(Boolean).join(' ');
return (
<button
className={classNames}
disabled={disabled}
onClick={onClick}
{...props}
>
{children}
</button>
);
};
Button.propTypes = {
children: PropTypes.node.isRequired,
variant: PropTypes.oneOf(['primary', 'secondary', 'danger']),
size: PropTypes.oneOf(['small', 'medium', 'large']),
disabled: PropTypes.bool,
onClick: PropTypes.func,
};
export default Button;
Components that handle business logic and data fetching:
// src/components/containers/UserList/UserList.jsx
import React, { useState, useEffect } from 'react';
import { useUsers } from '../../../hooks/useUsers';
import UserCard from '../../common/UserCard/UserCard';
import LoadingSpinner from '../../common/LoadingSpinner/LoadingSpinner';
import ErrorMessage from '../../common/ErrorMessage/ErrorMessage';
const UserList = () => {
const { users, loading, error, refetch } = useUsers();
const [filter, setFilter] = useState('');
const filteredUsers = users.filter(user =>
user.name.toLowerCase().includes(filter.toLowerCase())
);
if (loading) return <LoadingSpinner />;
if (error) return <ErrorMessage message={error.message} onRetry={refetch} />;
return (
<div className="user-list">
<div className="user-list__header">
<h2>Team Members</h2>
<input
type="text"
placeholder="Search users..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="user-list__search"
/>
</div>
<div className="user-list__grid">
{filteredUsers.map(user => (
<UserCard key={user.id} user={user} />
))}
</div>
{filteredUsers.length === 0 && (
<div className="user-list__empty">
No users found matching "{filter}"
</div>
)}
</div>
);
};
export default UserList;
// src/components/hoc/withAuth.jsx
import React from 'react';
import { useAuth } from '../../hooks/useAuth';
import UnauthorizedAccess from '../common/UnauthorizedAccess/UnauthorizedAccess';
const withAuth = (requiredPermissions = []) => (WrappedComponent) => {
const AuthorizedComponent = (props) => {
const { user, hasPermissions } = useAuth();
if (!user) {
return <UnauthorizedAccess message="Please log in to access this page" />;
}
if (requiredPermissions.length > 0 && !hasPermissions(requiredPermissions)) {
return <UnauthorizedAccess message="You don't have permission to access this page" />;
}
return <WrappedComponent {...props} />;
};
AuthorizedComponent.displayName = `withAuth(${WrappedComponent.displayName || WrappedComponent.name})`;
return AuthorizedComponent;
};
export default withAuth;
// src/components/common/DataFetcher/DataFetcher.jsx
import React, { useState, useEffect } from 'react';
const DataFetcher = ({ url, children }) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return children({ data, loading, error });
};
export default DataFetcher;
// Usage
<DataFetcher url="/api/users">
{({ data, loading, error }) => (
<>
{loading && <LoadingSpinner />}
{error && <ErrorMessage message={error.message} />}
{data && <UserList users={data} />}
</>
)}
</DataFetcher>
Choose the right state management approach based on your needs:
For component-specific state:
const SearchInput = ({ onSearch }) => {
const [query, setQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
const handleSearch = (value) => {
setQuery(value);
onSearch(value);
};
return (
<div className="search-input">
<input
value={query}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search..."
/>
{suggestions.length > 0 && (
<SuggestionsList suggestions={suggestions} />
)}
</div>
);
};
For application-wide state that doesn't require complex logic:
// src/store/AppContext.jsx
import React, { createContext, useContext, useReducer } from 'react';
const AppContext = createContext();
const initialState = {
user: null,
theme: 'light',
notifications: [],
loading: false,
};
const appReducer = (state, action) => {
switch (action.type) {
case 'SET_USER':
return { ...state, user: action.payload };
case 'SET_THEME':
return { ...state, theme: action.payload };
case 'ADD_NOTIFICATION':
return {
...state,
notifications: [...state.notifications, action.payload],
};
case 'REMOVE_NOTIFICATION':
return {
...state,
notifications: state.notifications.filter(
(notification) => notification.id !== action.payload
),
};
case 'SET_LOADING':
return { ...state, loading: action.payload };
default:
return state;
}
};
export const AppProvider = ({ children }) => {
const [state, dispatch] = useReducer(appReducer, initialState);
const actions = {
setUser: (user) => dispatch({ type: 'SET_USER', payload: user }),
setTheme: (theme) => dispatch({ type: 'SET_THEME', payload: theme }),
addNotification: (notification) =>
dispatch({
type: 'ADD_NOTIFICATION',
payload: { ...notification, id: Date.now() },
}),
removeNotification: (id) =>
dispatch({ type: 'REMOVE_NOTIFICATION', payload: id }),
setLoading: (loading) => dispatch({ type: 'SET_LOADING', payload: loading }),
};
return (
<AppContext.Provider value={{ state, actions }}>
{children}
</AppContext.Provider>
);
};
export const useAppContext = () => {
const context = useContext(AppContext);
if (!context) {
throw new Error('useAppContext must be used within an AppProvider');
}
return context;
};
For applications with complex state logic and time-travel debugging needs:
// src/store/userSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import userService from '../services/userService';
export const fetchUsers = createAsyncThunk(
'users/fetchUsers',
async (params, { rejectWithValue }) => {
try {
const response = await userService.getUsers(params);
return response.data;
} catch (error) {
return rejectWithValue(error.response.data);
}
}
);
const userSlice = createSlice({
name: 'users',
initialState: {
list: [],
loading: false,
error: null,
selectedUser: null,
},
reducers: {
setSelectedUser: (state, action) => {
state.selectedUser = action.payload;
},
clearError: (state) => {
state.error = null;
},
},
extraReducers: (builder) => {
builder
.addCase(fetchUsers.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchUsers.fulfilled, (state, action) => {
state.loading = false;
state.list = action.payload;
})
.addCase(fetchUsers.rejected, (state, action) => {
state.loading = false;
state.error = action.payload;
});
},
});
export const { setSelectedUser, clearError } = userSlice.actions;
export default userSlice.reducer;
Abstract complex state logic into custom hooks:
// src/hooks/useAsync.js
import { useState, useEffect, useCallback } from 'react';
const useAsync = (asyncFunction, immediate = true) => {
const [status, setStatus] = useState('idle');
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const execute = useCallback(
async (...args) => {
setStatus('pending');
setData(null);
setError(null);
try {
const response = await asyncFunction(...args);
setData(response);
setStatus('success');
return response;
} catch (err) {
setError(err);
setStatus('error');
throw err;
}
},
[asyncFunction]
);
useEffect(() => {
if (immediate) {
execute();
}
}, [execute, immediate]);
return {
execute,
status,
data,
error,
isLoading: status === 'pending',
isSuccess: status === 'success',
isError: status === 'error',
};
};
export default useAsync;
// Usage
const UserProfile = ({ userId }) => {
const {
data: user,
isLoading,
isError,
error,
execute: refetchUser
} = useAsync(() => userService.getUser(userId));
if (isLoading) return <LoadingSpinner />;
if (isError) return <ErrorMessage message={error.message} onRetry={refetchUser} />;
return <UserDetails user={user} />;
};
Prevent unnecessary re-renders with React.memo:
// src/components/common/ExpensiveComponent/ExpensiveComponent.jsx
import React, { memo } from 'react';
const ExpensiveComponent = memo(({ data, onAction }) => {
console.log('ExpensiveComponent rendered');
const processedData = React.useMemo(() => {
return data.map(item => ({
...item,
processed: heavyCalculation(item)
}));
}, [data]);
return (
<div>
{processedData.map(item => (
<div key={item.id} onClick={() => onAction(item)}>
{item.name}
</div>
))}
</div>
);
}, (prevProps, nextProps) => {
// Custom comparison function
return (
prevProps.data.length === nextProps.data.length &&
prevProps.data.every((item, index) =>
item.id === nextProps.data[index].id &&
item.version === nextProps.data[index].version
)
);
});
const heavyCalculation = (item) => {
// Expensive operation
let result = 0;
for (let i = 0; i < 10000; i++) {
result += item.value * Math.random();
}
return result;
};
export default ExpensiveComponent;
Optimize expensive calculations and function references:
// src/components/containers/DataVisualization/DataVisualization.jsx
import React, { useMemo, useCallback, useState } from 'react';
const DataVisualization = ({ rawData, filters }) => {
const [selectedDataPoint, setSelectedDataPoint] = useState(null);
// Memoize expensive data processing
const processedData = useMemo(() => {
console.log('Processing data...');
return rawData
.filter(item =>
!filters.category || item.category === filters.category
)
.filter(item =>
!filters.dateRange ||
(item.date >= filters.dateRange.start && item.date <= filters.dateRange.end)
)
.map(item => ({
...item,
normalized: normalizeValue(item.value, rawData),
trend: calculateTrend(item, rawData)
}))
.sort((a, b) => b.normalized - a.normalized);
}, [rawData, filters]);
// Memoize callback functions
const handleDataPointClick = useCallback((dataPoint) => {
setSelectedDataPoint(dataPoint);
// Additional logic here
}, []);
const handleReset = useCallback(() => {
setSelectedDataPoint(null);
}, []);
return (
<div className="data-visualization">
<ChartComponent
data={processedData}
onDataPointClick={handleDataPointClick}
/>
{selectedDataPoint && (
<DataPointDetails
dataPoint={selectedDataPoint}
onClose={handleReset}
/>
)}
</div>
);
};
const normalizeValue = (value, dataset) => {
const max = Math.max(...dataset.map(item => item.value));
const min = Math.min(...dataset.map(item => item.value));
return (value - min) / (max - min);
};
const calculateTrend = (item, dataset) => {
// Complex trend calculation
return 'upward'; // Simplified
};
export default DataVisualization;
Handle large datasets efficiently:
// src/components/common/VirtualList/VirtualList.jsx
import React, { useState, useEffect, useMemo } from 'react';
const VirtualList = ({
items,
itemHeight,
containerHeight,
renderItem,
overscan = 5
}) => {
const [scrollTop, setScrollTop] = useState(0);
const visibleItems = useMemo(() => {
const startIndex = Math.floor(scrollTop / itemHeight);
const endIndex = Math.min(
startIndex + Math.ceil(containerHeight / itemHeight) + overscan,
items.length - 1
);
const visibleItems = [];
for (let i = Math.max(0, startIndex - overscan); i <= endIndex; i++) {
visibleItems.push({
index: i,
item: items[i],
top: i * itemHeight,
});
}
return visibleItems;
}, [items, itemHeight, containerHeight, scrollTop, overscan]);
const totalHeight = items.length * itemHeight;
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={handleScroll}
>
<div style={{ height: totalHeight, position: 'relative' }}>
{visibleItems.map(({ index, item, top }) => (
<div
key={index}
style={{
position: 'absolute',
top,
left: 0,
right: 0,
height: itemHeight,
}}
>
{renderItem(item, index)}
</div>
))}
</div>
</div>
);
};
export default VirtualList;
// Usage
const LargeItemList = ({ items }) => {
return (
<VirtualList
items={items}
itemHeight={60}
containerHeight={400}
renderItem={(item, index) => (
<div className="list-item">
<h4>{item.title}</h4>
<p>{item.description}</p>
</div>
)}
/>
);
};
Split your application by routes:
// src/App.jsx
import React, { Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import LoadingSpinner from './components/common/LoadingSpinner/LoadingSpinner';
import ErrorBoundary from './components/common/ErrorBoundary/ErrorBoundary';
// Lazy load route components
const Home = lazy(() => import('./pages/Home/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard/Dashboard'));
const UserManagement = lazy(() => import('./pages/UserManagement/UserManagement'));
const Analytics = lazy(() => import('./pages/Analytics/Analytics'));
const Settings = lazy(() => import('./pages/Settings/Settings'));
const App = () => {
return (
<Router>
<ErrorBoundary>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/users" element={<UserManagement />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</ErrorBoundary>
</Router>
);
};
export default App;
Split heavy components:
// src/components/containers/DataAnalytics/DataAnalytics.jsx
import React, { useState, Suspense, lazy } from 'react';
const HeavyChart = lazy(() => import('../HeavyChart/HeavyChart'));
const DataTable = lazy(() => import('../DataTable/DataTable'));
const DataAnalytics = ({ data }) => {
const [activeView, setActiveView] = useState('chart');
return (
<div className="data-analytics">
<div className="data-analytics__header">
<button
onClick={() => setActiveView('chart')}
className={activeView === 'chart' ? 'active' : ''}
>
Chart View
</button>
<button
onClick={() => setActiveView('table')}
className={activeView === 'table' ? 'active' : ''}
>
Table View
</button>
</div>
<div className="data-analytics__content">
<Suspense fallback={<div>Loading view...</div>}>
{activeView === 'chart' ? (
<HeavyChart data={data} />
) : (
<DataTable data={data} />
)}
</Suspense>
</div>
</div>
);
};
export default DataAnalytics;
// src/components/common/Button/Button.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import Button from './Button';
describe('Button', () => {
test('renders button with text', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
});
test('calls onClick handler when clicked', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByRole('button'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled button</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
test('applies correct CSS classes for variants', () => {
const { rerender } = render(<Button variant="primary">Primary</Button>);
expect(screen.getByRole('button')).toHaveClass('button--primary');
rerender(<Button variant="secondary">Secondary</Button>);
expect(screen.getByRole('button')).toHaveClass('button--secondary');
});
});
// src/components/containers/UserList/UserList.test.jsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import UserList from './UserList';
const mockUsers = [
{ id: 1, name: 'John Doe', email: 'john@example.com' },
{ id: 2, name: 'Jane Smith', email: 'jane@example.com' },
];
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.json(mockUsers));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UserList', () => {
test('displays loading state initially', () => {
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
test('displays users after loading', async () => {
render(<UserList />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
test('handles API error', async () => {
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Server error' }));
})
);
render(<UserList />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
// src/hooks/useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import useCounter from './useCounter';
describe('useCounter', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
test('increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrements count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(4);
});
test('resets count', () => {
const { result } = renderHook(() => useCounter(5));
act(() => {
result.current.increment();
result.current.reset();
});
expect(result.current.count).toBe(5);
});
});
// .eslintrc.json
{
"extends": [
"react-app",
"react-app/jest",
"@typescript-eslint/recommended"
],
"rules": {
"no-unused-vars": "error",
"no-console": "warn",
"react/prop-types": "error",
"react/jsx-key": "error",
"react/no-unused-state": "error",
"@typescript-eslint/no-unused-vars": "error"
},
"overrides": [
{
"files": ["**/*.test.js", "**/*.test.jsx"],
"rules": {
"no-console": "off"
}
}
]
}
// .prettierrc
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2
}
// package.json
{
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx}": [
"eslint --fix",
"prettier --write",
"git add"
]
}
}
// src/config/environments.js
const environments = {
development: {
API_BASE_URL: 'http://localhost:3001',
LOG_LEVEL: 'debug',
ENABLE_MOCK_API: true,
},
staging: {
API_BASE_URL: 'https://staging-api.example.com',
LOG_LEVEL: 'info',
ENABLE_MOCK_API: false,
},
production: {
API_BASE_URL: 'https://api.example.com',
LOG_LEVEL: 'error',
ENABLE_MOCK_API: false,
},
};
const getConfig = () => {
const env = process.env.NODE_ENV || 'development';
return environments[env];
};
export default getConfig();
// webpack.config.js (if ejected or using custom config)
const path = require('path');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
// ... other config
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
common: {
name: 'common',
minChunks: 2,
chunks: 'all',
enforce: true,
},
},
},
},
plugins: [
// Analyze bundle size
process.env.ANALYZE && new BundleAnalyzerPlugin(),
].filter(Boolean),
};
// src/utils/performanceMonitoring.js
export const measurePerformance = (name, fn) => {
return async (...args) => {
const start = performance.now();
const result = await fn(...args);
const end = performance.now();
console.log(`${name} took ${end - start} milliseconds`);
// Send to analytics service
if (process.env.NODE_ENV === 'production') {
analytics.track('performance_metric', {
operation: name,
duration: end - start,
timestamp: new Date().toISOString(),
});
}
return result;
};
};
// Usage
const fetchDataWithMetrics = measurePerformance('fetchUserData', fetchUserData);
/**
* UserCard component displays user information in a card format
*
* @param {Object} user - User object containing user data
* @param {string} user.id - Unique user identifier
* @param {string} user.name - User's full name
* @param {string} user.email - User's email address
* @param {string} user.avatar - URL to user's avatar image
* @param {Function} onEdit - Callback function called when edit button is clicked
* @param {Function} onDelete - Callback function called when delete button is clicked
* @param {boolean} isEditable - Whether the card should show edit/delete actions
*
* @example
* <UserCard
* user={{ id: '1', name: 'John Doe', email: 'john@example.com' }}
* onEdit={handleEdit}
* onDelete={handleDelete}
* isEditable={true}
* />
*/
const UserCard = ({ user, onEdit, onDelete, isEditable = false }) => {
// Component implementation
};
Building scalable React applications requires a combination of good architecture, proper state management, performance optimization, and robust testing strategies. Key takeaways:
Remember that scalability is not just about handling more users—it's about maintaining developer productivity and code quality as your application grows. Focus on creating a development environment that enables your team to move fast while maintaining high standards.
By following these patterns and practices, you'll be well-equipped to build React applications that can scale from prototype to production, serving millions of users while remaining maintainable and performant.