Building Scalable React Applications
Over the past decade, I've built React applications ranging from small marketing sites to enterprise platforms. The difference between a codebase that scales gracefully and one that becomes unmaintainable often comes down to early architectural decisions.
When I started building Catalyst PSA—a comprehensive professional services automation platform—I had to make critical decisions about architecture that would support a growing team and an expanding feature set. Here's what I learned from building large-scale React applications that actually work in production.
Component Architecture Principles
The foundation of a scalable React application is a well-designed component architecture. This goes far beyond just "breaking things into small components."
1. The Single Responsibility Principle
Each component should have one clear purpose. When a component starts doing too much, it's time to break it down.
Here's an example from a real project management system:
// Bad: Too many responsibilities
function ProjectDashboard() {
const [projects, setProjects] = useState([])
const [filters, setFilters] = useState({})
const [user, setUser] = useState(null)
const [notifications, setNotifications] = useState([])
useEffect(() => {
// Fetch projects
// Fetch user
// Fetch notifications
// Apply filters
// Set up WebSocket
}, [])
return (
<div>
{/* Hundreds of lines of JSX */}
</div>
)
}
// Good: Single responsibilities
function ProjectDashboard() {
return (
<DashboardLayout>
<DashboardHeader />
<ProjectFilters />
<ProjectList />
<NotificationCenter />
</DashboardLayout>
)
}2. Component Composition Patterns
Composition is React's superpower. Here's a pattern I use extensively in enterprise applications:
// Container/Presenter pattern for complex features
// Container handles data and business logic
function ProjectListContainer() {
const { projects, loading, error } = useProjects()
const { filters, setFilters } = useProjectFilters()
if (loading) return <ProjectListSkeleton />
if (error) return <ErrorState error={error} />
return (
<ProjectList
projects={projects}
filters={filters}
onFilterChange={setFilters}
/>
)
}
// Presenter focuses purely on rendering
function ProjectList({ projects, filters, onFilterChange }) {
return (
<div className="space-y-4">
<FilterBar filters={filters} onChange={onFilterChange} />
<div className="grid grid-cols-3 gap-4">
{projects.map(project => (
<ProjectCard key={project.id} project={project} />
))}
</div>
</div>
)
}3. Compound Components for Flexibility
For components that need to work together, the compound component pattern provides excellent flexibility:
// Flexible, composable card component
function Card({ children, className }) {
return <div className={`card ${className}`}>{children}</div>
}
Card.Header = function CardHeader({ children }) {
return <div className="card-header">{children}</div>
}
Card.Body = function CardBody({ children }) {
return <div className="card-body">{children}</div>
}
Card.Footer = function CardFooter({ children }) {
return <div className="card-footer">{children}</div>
}
// Usage - consumers can compose as needed
function ProjectCard({ project }) {
return (
<Card>
<Card.Header>
<h3>{project.title}</h3>
<StatusBadge status={project.status} />
</Card.Header>
<Card.Body>
<p>{project.description}</p>
<ProjectMetrics metrics={project.metrics} />
</Card.Body>
<Card.Footer>
<Button>View Details</Button>
</Card.Footer>
</Card>
)
}State Management at Scale
Choosing the right state management approach is critical. I've used all the major solutions in production, and here's when to use each.
Local State: useState
Start here. Most state should be local to the component that needs it.
function SearchInput() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const handleSearch = async (value: string) => {
setQuery(value)
const data = await searchAPI(value)
setResults(data)
}
return (
<div>
<input
value={query}
onChange={(e) => handleSearch(e.target.value)}
/>
<SearchResults results={results} />
</div>
)
}Context: For Widely-Shared State
Context is perfect for theme, authentication, and feature flags—things that many components need but don't change often.
// auth-context.tsx
interface AuthContext {
user: User | null
login: (credentials: Credentials) => Promise<void>
logout: () => Promise<void>
isAuthenticated: boolean
}
const AuthContext = createContext<AuthContext | undefined>(undefined)
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const login = async (credentials: Credentials) => {
const userData = await authAPI.login(credentials)
setUser(userData)
localStorage.setItem('token', userData.token)
}
const logout = async () => {
await authAPI.logout()
setUser(null)
localStorage.removeItem('token')
}
const value = {
user,
login,
logout,
isAuthenticated: !!user,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within AuthProvider')
}
return context
}Zustand: For Complex Global State
For Catalyst PSA, I used Zustand extensively. It's lightweight, doesn't require providers, and has excellent TypeScript support.
// stores/project-store.ts
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
interface ProjectState {
projects: Project[]
selectedProject: Project | null
filters: ProjectFilters
// Actions
setProjects: (projects: Project[]) => void
selectProject: (id: string) => void
updateFilters: (filters: Partial<ProjectFilters>) => void
addProject: (project: Project) => Promise<void>
updateProject: (id: string, updates: Partial<Project>) => Promise<void>
deleteProject: (id: string) => Promise<void>
}
export const useProjectStore = create<ProjectState>()(
devtools(
persist(
(set, get) => ({
projects: [],
selectedProject: null,
filters: {},
setProjects: (projects) => set({ projects }),
selectProject: (id) => {
const project = get().projects.find(p => p.id === id)
set({ selectedProject: project || null })
},
updateFilters: (newFilters) =>
set((state) => ({
filters: { ...state.filters, ...newFilters }
})),
addProject: async (project) => {
const created = await projectAPI.create(project)
set((state) => ({
projects: [...state.projects, created]
}))
},
updateProject: async (id, updates) => {
const updated = await projectAPI.update(id, updates)
set((state) => ({
projects: state.projects.map(p =>
p.id === id ? updated : p
)
}))
},
deleteProject: async (id) => {
await projectAPI.delete(id)
set((state) => ({
projects: state.projects.filter(p => p.id !== id),
selectedProject: state.selectedProject?.id === id
? null
: state.selectedProject
}))
},
}),
{ name: 'project-store' }
)
)
)
// Usage in components
function ProjectList() {
const { projects, filters, updateFilters } = useProjectStore()
return (
<div>
<FilterBar filters={filters} onChange={updateFilters} />
{projects.map(project => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)
}When to Use Redux
I use Redux when:
- The application has complex state interactions
- You need time-travel debugging
- The team is already familiar with Redux
For most new projects, Zustand is my preference—it's simpler and more flexible.
Custom Hooks: Extracting Reusable Logic
Custom hooks are one of React's most powerful features for code reuse. Here are patterns I use constantly.
Data Fetching Hook
interface UseQueryOptions<T> {
onSuccess?: (data: T) => void
onError?: (error: Error) => void
enabled?: boolean
}
function useQuery<T>(
key: string,
fetcher: () => Promise<T>,
options: UseQueryOptions<T> = {}
) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const { onSuccess, onError, enabled = true } = options
useEffect(() => {
if (!enabled) return
let cancelled = false
const fetchData = async () => {
try {
setLoading(true)
setError(null)
const result = await fetcher()
if (!cancelled) {
setData(result)
onSuccess?.(result)
}
} catch (err) {
if (!cancelled) {
const error = err instanceof Error ? err : new Error('Unknown error')
setError(error)
onError?.(error)
}
} finally {
if (!cancelled) {
setLoading(false)
}
}
}
fetchData()
return () => {
cancelled = true
}
}, [key, enabled])
return { data, loading, error }
}
// Usage
function ProjectDetails({ id }: { id: string }) {
const { data: project, loading, error } = useQuery(
`project-${id}`,
() => projectAPI.getById(id),
{
onSuccess: (project) => {
analytics.track('project_viewed', { id: project.id })
}
}
)
if (loading) return <Spinner />
if (error) return <Error error={error} />
if (!project) return <NotFound />
return <ProjectView project={project} />
}Form Handling Hook
interface UseFormOptions<T> {
initialValues: T
validate?: (values: T) => Partial<Record<keyof T, string>>
onSubmit: (values: T) => Promise<void> | void
}
function useForm<T extends Record<string, any>>({
initialValues,
validate,
onSubmit,
}: UseFormOptions<T>) {
const [values, setValues] = useState<T>(initialValues)
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
const [submitting, setSubmitting] = useState(false)
const handleChange = (name: keyof T, value: any) => {
setValues(prev => ({ ...prev, [name]: value }))
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: undefined }))
}
}
const handleBlur = (name: keyof T) => {
setTouched(prev => ({ ...prev, [name]: true }))
// Validate field on blur
if (validate) {
const fieldErrors = validate(values)
if (fieldErrors[name]) {
setErrors(prev => ({ ...prev, [name]: fieldErrors[name] }))
}
}
}
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
// Validate all fields
const validationErrors = validate?.(values) || {}
setErrors(validationErrors)
// Mark all fields as touched
const allTouched = Object.keys(values).reduce(
(acc, key) => ({ ...acc, [key]: true }),
{}
)
setTouched(allTouched)
// Don't submit if there are errors
if (Object.keys(validationErrors).length > 0) {
return
}
try {
setSubmitting(true)
await onSubmit(values)
} finally {
setSubmitting(false)
}
}
const reset = () => {
setValues(initialValues)
setErrors({})
setTouched({})
}
return {
values,
errors,
touched,
submitting,
handleChange,
handleBlur,
handleSubmit,
reset,
}
}
// Usage
function CreateProjectForm() {
const form = useForm({
initialValues: {
title: '',
description: '',
startDate: '',
},
validate: (values) => {
const errors: any = {}
if (!values.title) errors.title = 'Title is required'
if (values.title.length < 3) errors.title = 'Title must be at least 3 characters'
if (!values.startDate) errors.startDate = 'Start date is required'
return errors
},
onSubmit: async (values) => {
await projectAPI.create(values)
toast.success('Project created!')
},
})
return (
<form onSubmit={form.handleSubmit}>
<div>
<input
value={form.values.title}
onChange={(e) => form.handleChange('title', e.target.value)}
onBlur={() => form.handleBlur('title')}
/>
{form.touched.title && form.errors.title && (
<span className="error">{form.errors.title}</span>
)}
</div>
<button type="submit" disabled={form.submitting}>
{form.submitting ? 'Creating...' : 'Create Project'}
</button>
</form>
)
}Performance Optimization in Large Applications
Performance becomes critical as your application grows. Here are the strategies that matter most.
1. Code Splitting with React.lazy
Don't load code users don't need:
import { lazy, Suspense } from 'react'
// Lazy load heavy components
const ProjectGanttChart = lazy(() => import('./components/ProjectGanttChart'))
const ReportingDashboard = lazy(() => import('./components/ReportingDashboard'))
const AdminPanel = lazy(() => import('./components/AdminPanel'))
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<HomePage />} />
<Route
path="/projects/:id/gantt"
element={
<Suspense fallback={<ChartSkeleton />}>
<ProjectGanttChart />
</Suspense>
}
/>
<Route
path="/reports"
element={
<Suspense fallback={<DashboardSkeleton />}>
<ReportingDashboard />
</Suspense>
}
/>
</Routes>
</Router>
)
}2. Memoization: React.memo, useMemo, useCallback
Prevent unnecessary re-renders:
// Memoize expensive computations
function ProjectAnalytics({ projects }: { projects: Project[] }) {
const statistics = useMemo(() => {
// Expensive calculation
return calculateProjectStatistics(projects)
}, [projects]) // Only recalculate when projects change
return <StatisticsDisplay stats={statistics} />
}
// Memoize callback functions
function ProjectList({ projects }: { projects: Project[] }) {
const [selectedId, setSelectedId] = useState<string | null>(null)
// Without useCallback, this creates a new function on every render
// causing ProjectCard to re-render even if the project hasn't changed
const handleSelect = useCallback((id: string) => {
setSelectedId(id)
analytics.track('project_selected', { id })
}, []) // Empty deps - function never changes
return (
<div>
{projects.map(project => (
<ProjectCard
key={project.id}
project={project}
onSelect={handleSelect}
isSelected={project.id === selectedId}
/>
))}
</div>
)
}
// Memoize components that receive stable props
const ProjectCard = memo(function ProjectCard({
project,
onSelect,
isSelected
}: {
project: Project
onSelect: (id: string) => void
isSelected: boolean
}) {
return (
<div
className={isSelected ? 'selected' : ''}
onClick={() => onSelect(project.id)}
>
<h3>{project.title}</h3>
<p>{project.description}</p>
</div>
)
})3. Virtual Lists for Large Datasets
For lists with thousands of items, use virtualization:
import { useVirtualizer } from '@tanstack/react-virtual'
function LargeProjectList({ projects }: { projects: Project[] }) {
const parentRef = useRef<HTMLDivElement>(null)
const virtualizer = useVirtualizer({
count: projects.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 100, // Estimated height of each item
overscan: 5, // Render 5 items above/below visible area
})
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map(virtualRow => {
const project = projects[virtualRow.index]
return (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<ProjectCard project={project} />
</div>
)
})}
</div>
</div>
)
}Error Boundaries for Resilience
Errors in one part of your app shouldn't crash the entire application:
import { Component, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
}
interface State {
hasError: boolean
error: Error | null
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('ErrorBoundary caught error:', error, errorInfo)
this.props.onError?.(error, errorInfo)
// Send to error tracking service
errorTrackingService.logError(error, errorInfo)
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="error-boundary">
<h2>Something went wrong</h2>
<details>
<summary>Error details</summary>
<pre>{this.state.error?.message}</pre>
</details>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Try again
</button>
</div>
)
}
return this.props.children
}
}
// Usage: wrap components that might fail
function App() {
return (
<ErrorBoundary fallback={<ErrorPage />}>
<Dashboard />
</ErrorBoundary>
)
}Testing Strategy for Scale
A robust testing strategy is non-negotiable for large applications.
Unit Tests for Hooks and Utilities
import { renderHook, waitFor } from '@testing-library/react'
import { useQuery } from './useQuery'
describe('useQuery', () => {
it('fetches data successfully', async () => {
const fetcher = jest.fn().mockResolvedValue({ id: 1, name: 'Test' })
const { result } = renderHook(() =>
useQuery('test', fetcher)
)
expect(result.current.loading).toBe(true)
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.data).toEqual({ id: 1, name: 'Test' })
expect(result.current.error).toBeNull()
})
it('handles errors', async () => {
const error = new Error('Failed to fetch')
const fetcher = jest.fn().mockRejectedValue(error)
const { result } = renderHook(() =>
useQuery('test', fetcher)
)
await waitFor(() => {
expect(result.current.loading).toBe(false)
})
expect(result.current.error).toEqual(error)
expect(result.current.data).toBeNull()
})
})Integration Tests for Features
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { CreateProjectForm } from './CreateProjectForm'
import * as projectAPI from '@/api/projects'
jest.mock('@/api/projects')
describe('CreateProjectForm', () => {
it('creates project with valid data', async () => {
const mockCreate = jest.spyOn(projectAPI, 'create').mockResolvedValue({
id: '123',
title: 'New Project',
description: 'Test project',
})
render(<CreateProjectForm />)
fireEvent.change(screen.getByLabelText(/title/i), {
target: { value: 'New Project' }
})
fireEvent.change(screen.getByLabelText(/description/i), {
target: { value: 'Test project' }
})
fireEvent.click(screen.getByRole('button', { name: /create/i }))
await waitFor(() => {
expect(mockCreate).toHaveBeenCalledWith({
title: 'New Project',
description: 'Test project',
})
})
expect(screen.getByText(/project created/i)).toBeInTheDocument()
})
})Folder Structure for Large Applications
Here's the structure I use for enterprise applications:
src/
app/ # Next.js app directory or routing
(marketing)/
(app)/
api/
components/
common/ # Shared UI components
Button/
Button.tsx
Button.test.tsx
Button.stories.tsx
Card/
Modal/
features/ # Feature-specific components
projects/
timesheet/
reporting/
hooks/ # Custom hooks
useAuth.ts
useQuery.ts
useForm.ts
lib/ # Utilities and configurations
api/ # API client
utils/ # Helper functions
constants/
stores/ # State management
project-store.ts
user-store.ts
types/ # TypeScript types
models/
api/
global.d.ts
styles/ # Global styles
Conclusion
Building scalable React applications is about making the right architectural decisions early and maintaining discipline as the codebase grows. The patterns I've shared here—from component composition to state management to performance optimization—have proven themselves across multiple enterprise applications with hundreds of thousands of lines of code.
Remember:
- Start with simple patterns and add complexity only when needed
- Keep components focused with single responsibilities
- Choose state management based on actual needs, not hype
- Optimize performance proactively, not reactively
- Write tests for critical functionality
- Organize code logically from day one
After building applications that have grown from 10K to millions of lines of code, I can confidently say that following these patterns will set you up for success as your application scales.

Jason Cochran
Sofware Engineer | Cloud Consultant | Founder at Strataga
27 years of experience building enterprise software for oil & gas operators and startups. Specializing in SCADA systems, field data solutions, and AI-powered rapid development. Based in Midland, TX serving the Permian Basin.